feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming) - #604

Merged
moonming merged 3 commits into
mainfrom
feat/ensemble-model
Jun 15, 2026
Merged

feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming)#604
moonming merged 3 commits into
mainfrom
feat/ensemble-model

Conversation

@moonming

@moonmingmoonming commented Jun 15, 2026

Copy link
Copy Markdown
Member

Summary

First slice of the ensemble virtual-model feature (DP side, tracked in #601; CP umbrella api7/AISIX-Cloud#804). An ensemble model fans one /v1/chat/completions request out to a panel of N upstream models in parallel, then a judge model synthesizes a single answer. It extends the existing virtual-model machinery: routing picks one target; ensemble calls all and synthesizes.

This PR lands the config layer and the non-streaming dispatch. Streaming, per-target rate-limiting, and hardening are separate follow-ups (see Deferred) — so this does notclose#601.

What's in this PR

1a89a6e — config layer (new ensemble model kind)

  • EnsembleConfig { panel: [{model, temperature?, seed?, weight?}], judge: {model, synthesis_prompt?}, min_responses?, timeout_ms? } in aisix-core.
  • Model.ensemble + Model::is_ensemble() (DP convention: virtual-model kind = presence of the block, same as routing).
  • Both DP schemas updated in lockstep so an ensemble row is never silently dropped on the kine watch path: the schemars-generated schemas/resources/model.schema.json (+ a standalone ensemble.schema.json) and the hand-written runtime validator models/schema.rs::model_schema(), whose oneOf is now a 3-way XOR (direct | routing | ensemble).

8375c85 — non-streaming fan-out dispatch

  • ensemble.rs: pure executor run_ensemble (parallel join_all fan-out, min_responses gate, per-member temperature/seed override for self-ensemble diversity, judge synthesis with neutral Answer 1..N labels + per-candidate truncation, judge retry-once-on-transient), plus the production ProxyModelCaller that resolves each member → ProviderKey → Bridge via the existing routing helpers.
  • chat.rs::dispatch_ensemble: branches at the post-rate-limit-reservation seam; rejects tools/tool_choice and stream:true with 400; runs the output guardrail on the synthesized answer; commits the aggregate panel+judge tokens once against the single entry-level reservation; emits one usage event per sub-call (attempt_kind = panel/judge, sharing request_id) before the guardrail check so a blocked response still bills the full fan-out; suppresses the entry-level usage event (no double-emit).
  • The client sees the ensemble's own model name; panel/judge model names never reach the client (synthesized answer, response.model, or error messages).

Behavior / contract

  • Config-driven: the operator curates the panel + judge; clients just call the model name. No per-request panel override.
  • Self-ensemble works with a single provider key (panel = the same model with per-member temperature).
  • v1 scope: chat-only; no server-side web retrieval; tools/streaming rejected (see Deferred).

Deferred (separate PRs, tracked in #601)

  • Streamingstream:true is 400-rejected here; there is no single-ChatResponse→SSE path today, so it's net-new.
  • Per-target rate-limiting — today only the entry model is debited; per-panel-member reservation is net-new (avoids N× provider-quota amplification).
  • Hardening — the response cache key must include the ensemble config; misc edges.
  • [CP] api7/AISIX-Cloud#804 — schema/projection/dashboard so operators can create ensemble models; the dashboard must also learn the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion).

Independent audit (merge-gate)

An independent cold-review agent audited this change. It confirmed: no double-emit / double-commit; authorization consistent with routing (panel/judge are internal targets — no escalation beyond what routing already allows); sound snapshot lifetimes across the fan-out; model names not leaked to the client response body. It caught and we fixed:

  • HIGH — on an output-guardrail block, the per-sub-call usage events were skipped while the panel tokens were already committed → cp-api under-reported a blocked-but-billed request. Fixed (emit before the guardrail check on both paths; charge: None on block) and locked with ensemble_output_block_still_emits_panel_and_judge_usage.
  • LOW — a misconfigured judge's model name leaked into the client-visible error.message → redacted (detail kept in server logs).
  • LOWtimeout_ms doc/behavior mismatch → behavior made uniform (now also applies to the judge call) + doc corrected.

Test plan

  • cargo test -p aisix-core259 pass (config types, runtime-validator 3-way XOR, schema round-trip; e.g. model_ensemble_with_direct_fields_fails, model_ensemble_with_routing_fails).
  • cargo test -p aisix-proxy433 pass: 8 executor unit tests + 5 handler e2e through a real OpenAiBridge against wiremock:
    • ensemble_fans_out_to_panel_and_returns_judge_synthesis (judge answer returned, model == "council", no upstream-id leak)
    • ensemble_rejects_tool_requests_with_400, ensemble_rejects_streaming_with_400
    • ensemble_insufficient_panel_returns_502
    • ensemble_output_block_still_emits_panel_and_judge_usage (HIGH regression lock — asserts 2 panel + 1 judge usage events still fire on a block)
  • cargo clippy --all-targets -- -D warnings — clean; full workspace builds.
  • CP-side e2e (create an ensemble model via the dashboard → call it end-to-end) lands with api7/AISIX-Cloud#804.

Summary by CodeRabbit

New Features

  • Added ensemble model support for chat completions, running multiple panel models in parallel and using a designated judge model to synthesize the final response.
  • Supports per-panel sampling overrides plus configurable minimum successful responses and optional per-member timeouts.
  • Enforces ensemble-only behavior in chat completions, with validation for streaming/tool fields and improved error handling.

Bug Fixes

  • Ensured schema generation and validation include the new ensemble configuration, with strict JSON field rejection and correct canonical schema outputs.

Introduce a third virtual-model kind alongside direct and routing: an
`ensemble` model fans a chat request out to a panel of models and
synthesizes their responses via a judge model. This lands the config
layer only; DP fan-out/synthesis dispatch is a follow-up.
- New EnsembleConfig/PanelMember/Judge types (models/ensemble.rs),
mirroring the routing config shape (deny_unknown_fields, _or_default
accessors, per-member temperature/seed for self-ensemble diversity).
- Model.ensemble field + Model::is_ensemble().
- Runtime validator (models/schema.rs): add the ensemble block and
widen the direct-vs-routing oneOf to a three-way XOR
(direct | routing | ensemble), so an ensemble row is neither silently
dropped on the kine watch path nor allowed to mix shapes.
- Regenerate schemas; emit a standalone ensemble.schema.json for CP
consumption (parity with routing.schema.json).
Refs #601, api7/AISIX-Cloud#804.
Wire the ensemble virtual-model kind end-to-end (non-streaming). One
/v1/chat/completions request to an `ensemble` model fans out to its panel
of models in parallel, then a judge model synthesizes a single answer.
- ensemble.rs: pure executor `run_ensemble` (parallel fan-out, min_responses
gate, per-member temperature/seed for self-ensemble diversity, judge
synthesis with neutral candidate labels + per-candidate truncation, judge
retry-once-on-transient), plus the production `ProxyModelCaller` that
resolves each member display_name -> ProviderKey -> Bridge.
- chat.rs: `dispatch_ensemble` branch (after the entry-level rate-limit
reservation, before the failover/streaming machinery): rejects tools +
streaming with 400; runs the output guardrail on the synthesized answer;
commits the aggregate panel+judge tokens once; emits one usage event per
sub-call (attempt_kind panel/judge) sharing request_id, before the
guardrail check so a blocked response still bills the full fan-out;
suppresses the entry-level usage event to avoid double-emit.
- Client sees the ensemble's own model name; panel/judge model names never
reach the client (synthesized answer, response.model, or error messages).
- Tests: 8 executor unit tests + 5 handler e2e (fan-out->judge, tools/stream
400, insufficient-panel->502, output-block-still-bills-panel+judge).
Streaming is rejected for now (no single-response->SSE path); it lands in a
follow-up. Dashboard needs to recognize the new attempt_kind values
"panel"/"judge" (free-string on the wire; tracked CP-side).
Refs #601, api7/AISIX-Cloud#804.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5a349dc8-7f5e-4ad0-bc1f-8f3c51557012

📥 Commits

Reviewing files that changed from the base of the PR and between 8375c85 and 917d692.

📒 Files selected for processing (7)
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/ensemble.rs
  • crates/aisix-proxy/src/lib.rs
  • schemas/resources/ensemble.schema.json
  • schemas/resources/model.schema.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • schemas/resources/ensemble.schema.json
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/ensemble.rs

📝 Walkthrough

Walkthrough

Adds "ensemble" model support across the proxy stack: new PanelMember, Judge, and EnsembleConfig Rust types in aisix-core, an optional ensemble field on Model, updated Admin API JSON schema validation with mutual exclusivity rules, a new run_ensemble orchestration engine in aisix-proxy that fans out to panel members concurrently and uses a judge model to synthesize responses, and a dispatch_ensemble path in the chat dispatch loop.

Changes

Ensemble Model Feature

Layer / File(s)Summary
Ensemble data model types and module wiring
crates/aisix-core/src/models/ensemble.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/models/model.rs
Defines PanelMember, Judge, and EnsembleConfig structs with deny_unknown_fields, helper methods (min_responses_or_default(), timeout()), and convenience constructors. Adds optional ensemble: Option<EnsembleConfig> field and is_ensemble() method to Model. Re-exports all three types from the models module. Unit tests cover deserialization, clamping to panel size, sentinel handling for timeout_ms=0, rejection of unknown fields, and constructor behavior.
JSON schema definitions and Admin API validation
crates/aisix-core/src/models/schema.rs, schemas/resources/ensemble.schema.json, schemas/resources/model.schema.json, crates/aisix-core/src/bin/dump-schema.rs
Adds ensemble object schema inside model_schema() with required panel/judge structure and field constraints. Updates oneOf XOR rules so ensemble is mutually exclusive with routing, direct upstream fields, background_model_check, and cooldown. Introduces standalone ensemble.schema.json and extends model.schema.json with EnsembleConfig/Judge/PanelMember definitions under additionalProperties: false. Registers EnsembleConfig in the dump-schema binary. Schema tests cover valid payloads (including with top-level allowed_cidrs and rate_limit) and failure cases for mutual exclusivity and malformed panel structure.
Ensemble orchestration engine
crates/aisix-proxy/src/ensemble.rs
Introduces ModelCaller trait and ProxyModelCaller (resolves models via AisixSnapshot, builds BridgeContext with optional deadline, dispatches via bridge.chat). Implements run_ensemble that concurrently fans out to panel members applying per-member temperature/seed overrides and forcing non-streaming, enforces min_responses_or_default(), builds a labeled judge prompt (with per-candidate truncation at UTF-8 boundaries), and calls the judge via call_judge_with_retry with transient-error classification. Defines PanelOutcome, EnsembleOutcome, and EnsembleError with http_status() (panel exhaustion → 502; judge failures delegated). Unit tests cover fan-out, insufficient panel, partial success when minimum is met, judge retry semantics (once on transient, not on non-transient), per-member temperature overrides vs inherited request temperature, and prompt privacy (no panel model-name leakage).
Chat dispatch integration
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/dispatch.rs, crates/aisix-proxy/src/lib.rs
Skips single-target provider/bridge preflight validation for ensemble models via a new guard in require_provider. In dispatch, adds an early is_ensemble() branch that routes to a new dispatch_ensemble function before streaming/failover routing. dispatch_ensemble rejects ensemble requests containing tools or forcing tool_choice with 400, rejects stream: true requests, runs run_ensemble, and on error commits survivor panel tokens while emitting per-panel UsageEvents before returning mapped DispatchFailure. On success, aggregates panel+judge tokens once, emits per-panel (attempt_kind: "panel") and per-judge (attempt_kind: "judge") UsageEvents with re-resolved sub-model identifiers, evaluates output guardrails on the synthesized response (emits guardrail_blocked on Block), and returns Success with telemetry_handled_by_stream suppression. Declares ensemble module in lib.rs.
End-to-end integration tests
crates/aisix-proxy/src/lib.rs
Adds test helpers (direct_model_entry, ensemble_model_entry, ensemble_model_entry_min, mount_panel_and_judge) and comprehensive integration tests: successful fan-out + judge synthesis with client receiving synthesized answer under ensemble model name and judge usage; rejection of ensemble requests containing tools (400); rejection of stream: true (400); output guardrail blocking judge with correct per-sub-call usage telemetry (panel members and judge, guardrail_blocked set); min_responses shortfall mapping to 502 with usage telemetry only for surviving calls; empty tools: [] and tool_choice: "none" allowance; forced tool_choice objects rejected (400); misconfigured judge error messages do not leak identifiers; non-chat endpoints (e.g., /v1/embeddings) reject ensemble with explicit error message; judge upstream 5xx collapses to 502 while billing panel members and emitting no judge usage when judge fails.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant dispatch as chat.rs<br/>dispatch
participant dispatch_ensemble as dispatch_ensemble
participant run_ensemble as run_ensemble
participant ProxyModelCaller as ProxyModelCaller
rect rgba(70, 130, 180, 0.5)
Note over dispatch,ProxyModelCaller: Panel fan-out
Client->>dispatch: POST /v1/chat/completions<br/>(ensemble model)
dispatch->>dispatch: is_ensemble() check
dispatch->>dispatch_ensemble: validate tools,<br/>stream, ensemble config
dispatch_ensemble->>run_ensemble: run_ensemble(req, config, caller)
run_ensemble->>ProxyModelCaller: call(panel_member, panel_request) ×N
ProxyModelCaller-->>run_ensemble: collect successes
run_ensemble->>run_ensemble: enforce<br/>min_responses_or_default()
end
rect rgba(60, 179, 113, 0.5)
Note over run_ensemble,ProxyModelCaller: Judge synthesis with retry
run_ensemble->>run_ensemble: build judge_request<br/>with labeled candidates
run_ensemble->>ProxyModelCaller: call_judge_with_retry(judge)
ProxyModelCaller-->>run_ensemble: EnsembleOutcome
end
rect rgba(220, 100, 60, 0.5)
Note over dispatch_ensemble,Client: Guardrails + telemetry
dispatch_ensemble->>dispatch_ensemble: emit UsageEvents<br/>per panel member & judge
dispatch_ensemble->>dispatch_ensemble: evaluate output guardrails
dispatch_ensemble-->>Client: synthesized ChatResponse<br/>(or 400/502/ContentFiltered)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

An independent audit of #604 found a client-facing leak, a CI-blocking
schema drift, and billing gaps on the ensemble error exits.
- Redact ensemble member identity from client errors: a misconfigured
judge's display_name + provider_key_id no longer reach error.message
(the resolve_provider_key path still forwarded it). Detail kept in logs.
- Regenerate ensemble/model schema JSON so the schema-drift CI gate
passes (the timeout_ms doc edit hadn't been regenerated).
- Bill the panel on every error exit, not just success. Panel members
that already round-tripped upstream now emit usage + commit tokens on
the insufficient-panel (502) and judge-failure paths, matching the
output-guardrail-block path. EnsembleError carries the surviving
PanelOutcomes.
- tools: [] (empty) no longer 400s; only a non-empty tools array or a
forcing tool_choice rejects (many SDKs always send tools: []).
- Non-chat endpoints reject an ensemble model with an explicit, accurate
400 (was incidental and said "routing"); added coverage.
- Remove dead EnsembleConfig::is_empty().
Tests: +6 e2e (judge-error redaction, 502-path + judge-failure panel
billing, tools:[] accepted, non-chat 400). 259 core + 439 proxy green;
clippy -D warnings clean; schema-drift gate clean.
Refs #601.
@moonming

Copy link
Copy Markdown
MemberAuthor

Post-PR independent audit — all findings resolved (917d692)

Per our merge-gate, an independent cold-review agent audited this PR. Findings and resolutions:

HIGH

  • Client-facing leak — a misconfigured judge's display_name + provider_key_id reached the client error.message (the earlier redaction only covered the get_by_name path; resolve_provider_key still forwarded it). Fixed — redacted; detail kept in server logs. Test: ensemble_misconfigured_judge_does_not_leak_internal_config.
  • Schema-drift CI would be red — the timeout_ms doc edit wasn't regenerated into the schema JSON. Fixed — regenerated; dump-schema + git diff --exit-code schemas/ clean.

MEDIUM

  • Billing under-report on error exits — panel members that already round-tripped upstream weren't billed on the insufficient-panel (502) path. Fixed — emit usage + commit tokens for the survivors on the 502 path, matching the output-guardrail-block path. Also extended to the judge-failure path (same class, surfaced during the fix). Tests: ensemble_insufficient_panel_returns_502, ensemble_judge_failure_still_bills_panel.
  • tools: [] wrongly 400'd — empty arrays (which many SDKs always send) were rejected. Fixed — only a non-empty tools or a forcing tool_choice rejects. Tests: ensemble_allows_empty_tools_array, ensemble_allows_tool_choice_none.
  • Non-chat rejection incidental + misleading — non-chat endpoints rejected ensemble models with a "routing" message and no test. Fixed — explicit, accurate 400. Test: ensemble_model_on_embeddings_returns_400_with_explicit_message.

Verified clean by the audit: the 3-way schema XOR (direct | routing | ensemble), single token-commit, double-emit suppression, response-body redaction, and the cross-repo wire contract vs api7/AISIX-Cloud#804.

Tracked follow-ups (not blocking this PR):

  • Total judge-prompt budget cap (per-candidate truncation exists; a panel-wide cap folds into the hardening PR under [DP] ensemble model — parallel panel fan-out + judge synthesis #601).
  • [CP] the dashboard should recognize the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion) — tracked under api7/AISIX-Cloud#804.

259 core + 439 proxy tests green; clippy --all-targets -- -D warnings clean; schema-drift gate clean.

@moonming
moonming merged commit ca2542e into mainJun 15, 2026
10 checks passed
@moonming
moonming deleted the feat/ensemble-model branch June 15, 2026 03:15
moonming added a commit that referenced this pull request Jun 15, 2026
Streams the judge's synthesized answer for ensemble models (stream:true). Panel buffers non-streaming, then the judge's tokens stream via the existing build_sse_stream (reused unmodified). Independently audited: safe to merge. Follows #604; tracked in #601.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DP] ensemble model — parallel panel fan-out + judge synthesis

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming) - #604

Merged
moonming merged 3 commits into
mainfrom
feat/ensemble-model
Jun 15, 2026
Merged

feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming)#604
moonming merged 3 commits into
mainfrom
feat/ensemble-model

Conversation

@moonming

@moonmingmoonming commented Jun 15, 2026

Copy link
Copy Markdown
Member

Summary

First slice of the ensemble virtual-model feature (DP side, tracked in #601; CP umbrella api7/AISIX-Cloud#804). An ensemble model fans one /v1/chat/completions request out to a panel of N upstream models in parallel, then a judge model synthesizes a single answer. It extends the existing virtual-model machinery: routing picks one target; ensemble calls all and synthesizes.

This PR lands the config layer and the non-streaming dispatch. Streaming, per-target rate-limiting, and hardening are separate follow-ups (see Deferred) — so this does notclose#601.

What's in this PR

1a89a6e — config layer (new ensemble model kind)

  • EnsembleConfig { panel: [{model, temperature?, seed?, weight?}], judge: {model, synthesis_prompt?}, min_responses?, timeout_ms? } in aisix-core.
  • Model.ensemble + Model::is_ensemble() (DP convention: virtual-model kind = presence of the block, same as routing).
  • Both DP schemas updated in lockstep so an ensemble row is never silently dropped on the kine watch path: the schemars-generated schemas/resources/model.schema.json (+ a standalone ensemble.schema.json) and the hand-written runtime validator models/schema.rs::model_schema(), whose oneOf is now a 3-way XOR (direct | routing | ensemble).

8375c85 — non-streaming fan-out dispatch

  • ensemble.rs: pure executor run_ensemble (parallel join_all fan-out, min_responses gate, per-member temperature/seed override for self-ensemble diversity, judge synthesis with neutral Answer 1..N labels + per-candidate truncation, judge retry-once-on-transient), plus the production ProxyModelCaller that resolves each member → ProviderKey → Bridge via the existing routing helpers.
  • chat.rs::dispatch_ensemble: branches at the post-rate-limit-reservation seam; rejects tools/tool_choice and stream:true with 400; runs the output guardrail on the synthesized answer; commits the aggregate panel+judge tokens once against the single entry-level reservation; emits one usage event per sub-call (attempt_kind = panel/judge, sharing request_id) before the guardrail check so a blocked response still bills the full fan-out; suppresses the entry-level usage event (no double-emit).
  • The client sees the ensemble's own model name; panel/judge model names never reach the client (synthesized answer, response.model, or error messages).

Behavior / contract

  • Config-driven: the operator curates the panel + judge; clients just call the model name. No per-request panel override.
  • Self-ensemble works with a single provider key (panel = the same model with per-member temperature).
  • v1 scope: chat-only; no server-side web retrieval; tools/streaming rejected (see Deferred).

Deferred (separate PRs, tracked in #601)

  • Streamingstream:true is 400-rejected here; there is no single-ChatResponse→SSE path today, so it's net-new.
  • Per-target rate-limiting — today only the entry model is debited; per-panel-member reservation is net-new (avoids N× provider-quota amplification).
  • Hardening — the response cache key must include the ensemble config; misc edges.
  • [CP] api7/AISIX-Cloud#804 — schema/projection/dashboard so operators can create ensemble models; the dashboard must also learn the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion).

Independent audit (merge-gate)

An independent cold-review agent audited this change. It confirmed: no double-emit / double-commit; authorization consistent with routing (panel/judge are internal targets — no escalation beyond what routing already allows); sound snapshot lifetimes across the fan-out; model names not leaked to the client response body. It caught and we fixed:

  • HIGH — on an output-guardrail block, the per-sub-call usage events were skipped while the panel tokens were already committed → cp-api under-reported a blocked-but-billed request. Fixed (emit before the guardrail check on both paths; charge: None on block) and locked with ensemble_output_block_still_emits_panel_and_judge_usage.
  • LOW — a misconfigured judge's model name leaked into the client-visible error.message → redacted (detail kept in server logs).
  • LOWtimeout_ms doc/behavior mismatch → behavior made uniform (now also applies to the judge call) + doc corrected.

Test plan

  • cargo test -p aisix-core259 pass (config types, runtime-validator 3-way XOR, schema round-trip; e.g. model_ensemble_with_direct_fields_fails, model_ensemble_with_routing_fails).
  • cargo test -p aisix-proxy433 pass: 8 executor unit tests + 5 handler e2e through a real OpenAiBridge against wiremock:
    • ensemble_fans_out_to_panel_and_returns_judge_synthesis (judge answer returned, model == "council", no upstream-id leak)
    • ensemble_rejects_tool_requests_with_400, ensemble_rejects_streaming_with_400
    • ensemble_insufficient_panel_returns_502
    • ensemble_output_block_still_emits_panel_and_judge_usage (HIGH regression lock — asserts 2 panel + 1 judge usage events still fire on a block)
  • cargo clippy --all-targets -- -D warnings — clean; full workspace builds.
  • CP-side e2e (create an ensemble model via the dashboard → call it end-to-end) lands with api7/AISIX-Cloud#804.

Summary by CodeRabbit

New Features

  • Added ensemble model support for chat completions, running multiple panel models in parallel and using a designated judge model to synthesize the final response.
  • Supports per-panel sampling overrides plus configurable minimum successful responses and optional per-member timeouts.
  • Enforces ensemble-only behavior in chat completions, with validation for streaming/tool fields and improved error handling.

Bug Fixes

  • Ensured schema generation and validation include the new ensemble configuration, with strict JSON field rejection and correct canonical schema outputs.

Introduce a third virtual-model kind alongside direct and routing: an
`ensemble` model fans a chat request out to a panel of models and
synthesizes their responses via a judge model. This lands the config
layer only; DP fan-out/synthesis dispatch is a follow-up.
- New EnsembleConfig/PanelMember/Judge types (models/ensemble.rs),
mirroring the routing config shape (deny_unknown_fields, _or_default
accessors, per-member temperature/seed for self-ensemble diversity).
- Model.ensemble field + Model::is_ensemble().
- Runtime validator (models/schema.rs): add the ensemble block and
widen the direct-vs-routing oneOf to a three-way XOR
(direct | routing | ensemble), so an ensemble row is neither silently
dropped on the kine watch path nor allowed to mix shapes.
- Regenerate schemas; emit a standalone ensemble.schema.json for CP
consumption (parity with routing.schema.json).
Refs #601, api7/AISIX-Cloud#804.
Wire the ensemble virtual-model kind end-to-end (non-streaming). One
/v1/chat/completions request to an `ensemble` model fans out to its panel
of models in parallel, then a judge model synthesizes a single answer.
- ensemble.rs: pure executor `run_ensemble` (parallel fan-out, min_responses
gate, per-member temperature/seed for self-ensemble diversity, judge
synthesis with neutral candidate labels + per-candidate truncation, judge
retry-once-on-transient), plus the production `ProxyModelCaller` that
resolves each member display_name -> ProviderKey -> Bridge.
- chat.rs: `dispatch_ensemble` branch (after the entry-level rate-limit
reservation, before the failover/streaming machinery): rejects tools +
streaming with 400; runs the output guardrail on the synthesized answer;
commits the aggregate panel+judge tokens once; emits one usage event per
sub-call (attempt_kind panel/judge) sharing request_id, before the
guardrail check so a blocked response still bills the full fan-out;
suppresses the entry-level usage event to avoid double-emit.
- Client sees the ensemble's own model name; panel/judge model names never
reach the client (synthesized answer, response.model, or error messages).
- Tests: 8 executor unit tests + 5 handler e2e (fan-out->judge, tools/stream
400, insufficient-panel->502, output-block-still-bills-panel+judge).
Streaming is rejected for now (no single-response->SSE path); it lands in a
follow-up. Dashboard needs to recognize the new attempt_kind values
"panel"/"judge" (free-string on the wire; tracked CP-side).
Refs #601, api7/AISIX-Cloud#804.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5a349dc8-7f5e-4ad0-bc1f-8f3c51557012

📥 Commits

Reviewing files that changed from the base of the PR and between 8375c85 and 917d692.

📒 Files selected for processing (7)
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/ensemble.rs
  • crates/aisix-proxy/src/lib.rs
  • schemas/resources/ensemble.schema.json
  • schemas/resources/model.schema.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • schemas/resources/ensemble.schema.json
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/ensemble.rs

📝 Walkthrough

Walkthrough

Adds "ensemble" model support across the proxy stack: new PanelMember, Judge, and EnsembleConfig Rust types in aisix-core, an optional ensemble field on Model, updated Admin API JSON schema validation with mutual exclusivity rules, a new run_ensemble orchestration engine in aisix-proxy that fans out to panel members concurrently and uses a judge model to synthesize responses, and a dispatch_ensemble path in the chat dispatch loop.

Changes

Ensemble Model Feature

Layer / File(s)Summary
Ensemble data model types and module wiring
crates/aisix-core/src/models/ensemble.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/models/model.rs
Defines PanelMember, Judge, and EnsembleConfig structs with deny_unknown_fields, helper methods (min_responses_or_default(), timeout()), and convenience constructors. Adds optional ensemble: Option<EnsembleConfig> field and is_ensemble() method to Model. Re-exports all three types from the models module. Unit tests cover deserialization, clamping to panel size, sentinel handling for timeout_ms=0, rejection of unknown fields, and constructor behavior.
JSON schema definitions and Admin API validation
crates/aisix-core/src/models/schema.rs, schemas/resources/ensemble.schema.json, schemas/resources/model.schema.json, crates/aisix-core/src/bin/dump-schema.rs
Adds ensemble object schema inside model_schema() with required panel/judge structure and field constraints. Updates oneOf XOR rules so ensemble is mutually exclusive with routing, direct upstream fields, background_model_check, and cooldown. Introduces standalone ensemble.schema.json and extends model.schema.json with EnsembleConfig/Judge/PanelMember definitions under additionalProperties: false. Registers EnsembleConfig in the dump-schema binary. Schema tests cover valid payloads (including with top-level allowed_cidrs and rate_limit) and failure cases for mutual exclusivity and malformed panel structure.
Ensemble orchestration engine
crates/aisix-proxy/src/ensemble.rs
Introduces ModelCaller trait and ProxyModelCaller (resolves models via AisixSnapshot, builds BridgeContext with optional deadline, dispatches via bridge.chat). Implements run_ensemble that concurrently fans out to panel members applying per-member temperature/seed overrides and forcing non-streaming, enforces min_responses_or_default(), builds a labeled judge prompt (with per-candidate truncation at UTF-8 boundaries), and calls the judge via call_judge_with_retry with transient-error classification. Defines PanelOutcome, EnsembleOutcome, and EnsembleError with http_status() (panel exhaustion → 502; judge failures delegated). Unit tests cover fan-out, insufficient panel, partial success when minimum is met, judge retry semantics (once on transient, not on non-transient), per-member temperature overrides vs inherited request temperature, and prompt privacy (no panel model-name leakage).
Chat dispatch integration
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/dispatch.rs, crates/aisix-proxy/src/lib.rs
Skips single-target provider/bridge preflight validation for ensemble models via a new guard in require_provider. In dispatch, adds an early is_ensemble() branch that routes to a new dispatch_ensemble function before streaming/failover routing. dispatch_ensemble rejects ensemble requests containing tools or forcing tool_choice with 400, rejects stream: true requests, runs run_ensemble, and on error commits survivor panel tokens while emitting per-panel UsageEvents before returning mapped DispatchFailure. On success, aggregates panel+judge tokens once, emits per-panel (attempt_kind: "panel") and per-judge (attempt_kind: "judge") UsageEvents with re-resolved sub-model identifiers, evaluates output guardrails on the synthesized response (emits guardrail_blocked on Block), and returns Success with telemetry_handled_by_stream suppression. Declares ensemble module in lib.rs.
End-to-end integration tests
crates/aisix-proxy/src/lib.rs
Adds test helpers (direct_model_entry, ensemble_model_entry, ensemble_model_entry_min, mount_panel_and_judge) and comprehensive integration tests: successful fan-out + judge synthesis with client receiving synthesized answer under ensemble model name and judge usage; rejection of ensemble requests containing tools (400); rejection of stream: true (400); output guardrail blocking judge with correct per-sub-call usage telemetry (panel members and judge, guardrail_blocked set); min_responses shortfall mapping to 502 with usage telemetry only for surviving calls; empty tools: [] and tool_choice: "none" allowance; forced tool_choice objects rejected (400); misconfigured judge error messages do not leak identifiers; non-chat endpoints (e.g., /v1/embeddings) reject ensemble with explicit error message; judge upstream 5xx collapses to 502 while billing panel members and emitting no judge usage when judge fails.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant dispatch as chat.rs<br/>dispatch
participant dispatch_ensemble as dispatch_ensemble
participant run_ensemble as run_ensemble
participant ProxyModelCaller as ProxyModelCaller
rect rgba(70, 130, 180, 0.5)
Note over dispatch,ProxyModelCaller: Panel fan-out
Client->>dispatch: POST /v1/chat/completions<br/>(ensemble model)
dispatch->>dispatch: is_ensemble() check
dispatch->>dispatch_ensemble: validate tools,<br/>stream, ensemble config
dispatch_ensemble->>run_ensemble: run_ensemble(req, config, caller)
run_ensemble->>ProxyModelCaller: call(panel_member, panel_request) ×N
ProxyModelCaller-->>run_ensemble: collect successes
run_ensemble->>run_ensemble: enforce<br/>min_responses_or_default()
end
rect rgba(60, 179, 113, 0.5)
Note over run_ensemble,ProxyModelCaller: Judge synthesis with retry
run_ensemble->>run_ensemble: build judge_request<br/>with labeled candidates
run_ensemble->>ProxyModelCaller: call_judge_with_retry(judge)
ProxyModelCaller-->>run_ensemble: EnsembleOutcome
end
rect rgba(220, 100, 60, 0.5)
Note over dispatch_ensemble,Client: Guardrails + telemetry
dispatch_ensemble->>dispatch_ensemble: emit UsageEvents<br/>per panel member & judge
dispatch_ensemble->>dispatch_ensemble: evaluate output guardrails
dispatch_ensemble-->>Client: synthesized ChatResponse<br/>(or 400/502/ContentFiltered)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

An independent audit of #604 found a client-facing leak, a CI-blocking
schema drift, and billing gaps on the ensemble error exits.
- Redact ensemble member identity from client errors: a misconfigured
judge's display_name + provider_key_id no longer reach error.message
(the resolve_provider_key path still forwarded it). Detail kept in logs.
- Regenerate ensemble/model schema JSON so the schema-drift CI gate
passes (the timeout_ms doc edit hadn't been regenerated).
- Bill the panel on every error exit, not just success. Panel members
that already round-tripped upstream now emit usage + commit tokens on
the insufficient-panel (502) and judge-failure paths, matching the
output-guardrail-block path. EnsembleError carries the surviving
PanelOutcomes.
- tools: [] (empty) no longer 400s; only a non-empty tools array or a
forcing tool_choice rejects (many SDKs always send tools: []).
- Non-chat endpoints reject an ensemble model with an explicit, accurate
400 (was incidental and said "routing"); added coverage.
- Remove dead EnsembleConfig::is_empty().
Tests: +6 e2e (judge-error redaction, 502-path + judge-failure panel
billing, tools:[] accepted, non-chat 400). 259 core + 439 proxy green;
clippy -D warnings clean; schema-drift gate clean.
Refs #601.
@moonming

Copy link
Copy Markdown
MemberAuthor

Post-PR independent audit — all findings resolved (917d692)

Per our merge-gate, an independent cold-review agent audited this PR. Findings and resolutions:

HIGH

  • Client-facing leak — a misconfigured judge's display_name + provider_key_id reached the client error.message (the earlier redaction only covered the get_by_name path; resolve_provider_key still forwarded it). Fixed — redacted; detail kept in server logs. Test: ensemble_misconfigured_judge_does_not_leak_internal_config.
  • Schema-drift CI would be red — the timeout_ms doc edit wasn't regenerated into the schema JSON. Fixed — regenerated; dump-schema + git diff --exit-code schemas/ clean.

MEDIUM

  • Billing under-report on error exits — panel members that already round-tripped upstream weren't billed on the insufficient-panel (502) path. Fixed — emit usage + commit tokens for the survivors on the 502 path, matching the output-guardrail-block path. Also extended to the judge-failure path (same class, surfaced during the fix). Tests: ensemble_insufficient_panel_returns_502, ensemble_judge_failure_still_bills_panel.
  • tools: [] wrongly 400'd — empty arrays (which many SDKs always send) were rejected. Fixed — only a non-empty tools or a forcing tool_choice rejects. Tests: ensemble_allows_empty_tools_array, ensemble_allows_tool_choice_none.
  • Non-chat rejection incidental + misleading — non-chat endpoints rejected ensemble models with a "routing" message and no test. Fixed — explicit, accurate 400. Test: ensemble_model_on_embeddings_returns_400_with_explicit_message.

Verified clean by the audit: the 3-way schema XOR (direct | routing | ensemble), single token-commit, double-emit suppression, response-body redaction, and the cross-repo wire contract vs api7/AISIX-Cloud#804.

Tracked follow-ups (not blocking this PR):

  • Total judge-prompt budget cap (per-candidate truncation exists; a panel-wide cap folds into the hardening PR under [DP] ensemble model — parallel panel fan-out + judge synthesis #601).
  • [CP] the dashboard should recognize the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion) — tracked under api7/AISIX-Cloud#804.

259 core + 439 proxy tests green; clippy --all-targets -- -D warnings clean; schema-drift gate clean.

@moonming
moonming merged commit ca2542e into mainJun 15, 2026
10 checks passed
@moonming
moonming deleted the feat/ensemble-model branch June 15, 2026 03:15
moonming added a commit that referenced this pull request Jun 15, 2026
Streams the judge's synthesized answer for ensemble models (stream:true). Panel buffers non-streaming, then the judge's tokens stream via the existing build_sse_stream (reused unmodified). Independently audited: safe to merge. Follows #604; tracked in #601.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DP] ensemble model — parallel panel fan-out + judge synthesis

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming) - #604

Merged
moonming merged 3 commits into
mainfrom
feat/ensemble-model
Jun 15, 2026
Merged

feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming)#604
moonming merged 3 commits into
mainfrom
feat/ensemble-model

Conversation

@moonming

@moonmingmoonming commented Jun 15, 2026

Copy link
Copy Markdown
Member

Summary

First slice of the ensemble virtual-model feature (DP side, tracked in #601; CP umbrella api7/AISIX-Cloud#804). An ensemble model fans one /v1/chat/completions request out to a panel of N upstream models in parallel, then a judge model synthesizes a single answer. It extends the existing virtual-model machinery: routing picks one target; ensemble calls all and synthesizes.

This PR lands the config layer and the non-streaming dispatch. Streaming, per-target rate-limiting, and hardening are separate follow-ups (see Deferred) — so this does notclose#601.

What's in this PR

1a89a6e — config layer (new ensemble model kind)

  • EnsembleConfig { panel: [{model, temperature?, seed?, weight?}], judge: {model, synthesis_prompt?}, min_responses?, timeout_ms? } in aisix-core.
  • Model.ensemble + Model::is_ensemble() (DP convention: virtual-model kind = presence of the block, same as routing).
  • Both DP schemas updated in lockstep so an ensemble row is never silently dropped on the kine watch path: the schemars-generated schemas/resources/model.schema.json (+ a standalone ensemble.schema.json) and the hand-written runtime validator models/schema.rs::model_schema(), whose oneOf is now a 3-way XOR (direct | routing | ensemble).

8375c85 — non-streaming fan-out dispatch

  • ensemble.rs: pure executor run_ensemble (parallel join_all fan-out, min_responses gate, per-member temperature/seed override for self-ensemble diversity, judge synthesis with neutral Answer 1..N labels + per-candidate truncation, judge retry-once-on-transient), plus the production ProxyModelCaller that resolves each member → ProviderKey → Bridge via the existing routing helpers.
  • chat.rs::dispatch_ensemble: branches at the post-rate-limit-reservation seam; rejects tools/tool_choice and stream:true with 400; runs the output guardrail on the synthesized answer; commits the aggregate panel+judge tokens once against the single entry-level reservation; emits one usage event per sub-call (attempt_kind = panel/judge, sharing request_id) before the guardrail check so a blocked response still bills the full fan-out; suppresses the entry-level usage event (no double-emit).
  • The client sees the ensemble's own model name; panel/judge model names never reach the client (synthesized answer, response.model, or error messages).

Behavior / contract

  • Config-driven: the operator curates the panel + judge; clients just call the model name. No per-request panel override.
  • Self-ensemble works with a single provider key (panel = the same model with per-member temperature).
  • v1 scope: chat-only; no server-side web retrieval; tools/streaming rejected (see Deferred).

Deferred (separate PRs, tracked in #601)

  • Streamingstream:true is 400-rejected here; there is no single-ChatResponse→SSE path today, so it's net-new.
  • Per-target rate-limiting — today only the entry model is debited; per-panel-member reservation is net-new (avoids N× provider-quota amplification).
  • Hardening — the response cache key must include the ensemble config; misc edges.
  • [CP] api7/AISIX-Cloud#804 — schema/projection/dashboard so operators can create ensemble models; the dashboard must also learn the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion).

Independent audit (merge-gate)

An independent cold-review agent audited this change. It confirmed: no double-emit / double-commit; authorization consistent with routing (panel/judge are internal targets — no escalation beyond what routing already allows); sound snapshot lifetimes across the fan-out; model names not leaked to the client response body. It caught and we fixed:

  • HIGH — on an output-guardrail block, the per-sub-call usage events were skipped while the panel tokens were already committed → cp-api under-reported a blocked-but-billed request. Fixed (emit before the guardrail check on both paths; charge: None on block) and locked with ensemble_output_block_still_emits_panel_and_judge_usage.
  • LOW — a misconfigured judge's model name leaked into the client-visible error.message → redacted (detail kept in server logs).
  • LOWtimeout_ms doc/behavior mismatch → behavior made uniform (now also applies to the judge call) + doc corrected.

Test plan

  • cargo test -p aisix-core259 pass (config types, runtime-validator 3-way XOR, schema round-trip; e.g. model_ensemble_with_direct_fields_fails, model_ensemble_with_routing_fails).
  • cargo test -p aisix-proxy433 pass: 8 executor unit tests + 5 handler e2e through a real OpenAiBridge against wiremock:
    • ensemble_fans_out_to_panel_and_returns_judge_synthesis (judge answer returned, model == "council", no upstream-id leak)
    • ensemble_rejects_tool_requests_with_400, ensemble_rejects_streaming_with_400
    • ensemble_insufficient_panel_returns_502
    • ensemble_output_block_still_emits_panel_and_judge_usage (HIGH regression lock — asserts 2 panel + 1 judge usage events still fire on a block)
  • cargo clippy --all-targets -- -D warnings — clean; full workspace builds.
  • CP-side e2e (create an ensemble model via the dashboard → call it end-to-end) lands with api7/AISIX-Cloud#804.

Summary by CodeRabbit

New Features

  • Added ensemble model support for chat completions, running multiple panel models in parallel and using a designated judge model to synthesize the final response.
  • Supports per-panel sampling overrides plus configurable minimum successful responses and optional per-member timeouts.
  • Enforces ensemble-only behavior in chat completions, with validation for streaming/tool fields and improved error handling.

Bug Fixes

  • Ensured schema generation and validation include the new ensemble configuration, with strict JSON field rejection and correct canonical schema outputs.

Introduce a third virtual-model kind alongside direct and routing: an
`ensemble` model fans a chat request out to a panel of models and
synthesizes their responses via a judge model. This lands the config
layer only; DP fan-out/synthesis dispatch is a follow-up.
- New EnsembleConfig/PanelMember/Judge types (models/ensemble.rs),
mirroring the routing config shape (deny_unknown_fields, _or_default
accessors, per-member temperature/seed for self-ensemble diversity).
- Model.ensemble field + Model::is_ensemble().
- Runtime validator (models/schema.rs): add the ensemble block and
widen the direct-vs-routing oneOf to a three-way XOR
(direct | routing | ensemble), so an ensemble row is neither silently
dropped on the kine watch path nor allowed to mix shapes.
- Regenerate schemas; emit a standalone ensemble.schema.json for CP
consumption (parity with routing.schema.json).
Refs #601, api7/AISIX-Cloud#804.
Wire the ensemble virtual-model kind end-to-end (non-streaming). One
/v1/chat/completions request to an `ensemble` model fans out to its panel
of models in parallel, then a judge model synthesizes a single answer.
- ensemble.rs: pure executor `run_ensemble` (parallel fan-out, min_responses
gate, per-member temperature/seed for self-ensemble diversity, judge
synthesis with neutral candidate labels + per-candidate truncation, judge
retry-once-on-transient), plus the production `ProxyModelCaller` that
resolves each member display_name -> ProviderKey -> Bridge.
- chat.rs: `dispatch_ensemble` branch (after the entry-level rate-limit
reservation, before the failover/streaming machinery): rejects tools +
streaming with 400; runs the output guardrail on the synthesized answer;
commits the aggregate panel+judge tokens once; emits one usage event per
sub-call (attempt_kind panel/judge) sharing request_id, before the
guardrail check so a blocked response still bills the full fan-out;
suppresses the entry-level usage event to avoid double-emit.
- Client sees the ensemble's own model name; panel/judge model names never
reach the client (synthesized answer, response.model, or error messages).
- Tests: 8 executor unit tests + 5 handler e2e (fan-out->judge, tools/stream
400, insufficient-panel->502, output-block-still-bills-panel+judge).
Streaming is rejected for now (no single-response->SSE path); it lands in a
follow-up. Dashboard needs to recognize the new attempt_kind values
"panel"/"judge" (free-string on the wire; tracked CP-side).
Refs #601, api7/AISIX-Cloud#804.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5a349dc8-7f5e-4ad0-bc1f-8f3c51557012

📥 Commits

Reviewing files that changed from the base of the PR and between 8375c85 and 917d692.

📒 Files selected for processing (7)
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/ensemble.rs
  • crates/aisix-proxy/src/lib.rs
  • schemas/resources/ensemble.schema.json
  • schemas/resources/model.schema.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • schemas/resources/ensemble.schema.json
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/ensemble.rs

📝 Walkthrough

Walkthrough

Adds "ensemble" model support across the proxy stack: new PanelMember, Judge, and EnsembleConfig Rust types in aisix-core, an optional ensemble field on Model, updated Admin API JSON schema validation with mutual exclusivity rules, a new run_ensemble orchestration engine in aisix-proxy that fans out to panel members concurrently and uses a judge model to synthesize responses, and a dispatch_ensemble path in the chat dispatch loop.

Changes

Ensemble Model Feature

Layer / File(s)Summary
Ensemble data model types and module wiring
crates/aisix-core/src/models/ensemble.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/models/model.rs
Defines PanelMember, Judge, and EnsembleConfig structs with deny_unknown_fields, helper methods (min_responses_or_default(), timeout()), and convenience constructors. Adds optional ensemble: Option<EnsembleConfig> field and is_ensemble() method to Model. Re-exports all three types from the models module. Unit tests cover deserialization, clamping to panel size, sentinel handling for timeout_ms=0, rejection of unknown fields, and constructor behavior.
JSON schema definitions and Admin API validation
crates/aisix-core/src/models/schema.rs, schemas/resources/ensemble.schema.json, schemas/resources/model.schema.json, crates/aisix-core/src/bin/dump-schema.rs
Adds ensemble object schema inside model_schema() with required panel/judge structure and field constraints. Updates oneOf XOR rules so ensemble is mutually exclusive with routing, direct upstream fields, background_model_check, and cooldown. Introduces standalone ensemble.schema.json and extends model.schema.json with EnsembleConfig/Judge/PanelMember definitions under additionalProperties: false. Registers EnsembleConfig in the dump-schema binary. Schema tests cover valid payloads (including with top-level allowed_cidrs and rate_limit) and failure cases for mutual exclusivity and malformed panel structure.
Ensemble orchestration engine
crates/aisix-proxy/src/ensemble.rs
Introduces ModelCaller trait and ProxyModelCaller (resolves models via AisixSnapshot, builds BridgeContext with optional deadline, dispatches via bridge.chat). Implements run_ensemble that concurrently fans out to panel members applying per-member temperature/seed overrides and forcing non-streaming, enforces min_responses_or_default(), builds a labeled judge prompt (with per-candidate truncation at UTF-8 boundaries), and calls the judge via call_judge_with_retry with transient-error classification. Defines PanelOutcome, EnsembleOutcome, and EnsembleError with http_status() (panel exhaustion → 502; judge failures delegated). Unit tests cover fan-out, insufficient panel, partial success when minimum is met, judge retry semantics (once on transient, not on non-transient), per-member temperature overrides vs inherited request temperature, and prompt privacy (no panel model-name leakage).
Chat dispatch integration
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/dispatch.rs, crates/aisix-proxy/src/lib.rs
Skips single-target provider/bridge preflight validation for ensemble models via a new guard in require_provider. In dispatch, adds an early is_ensemble() branch that routes to a new dispatch_ensemble function before streaming/failover routing. dispatch_ensemble rejects ensemble requests containing tools or forcing tool_choice with 400, rejects stream: true requests, runs run_ensemble, and on error commits survivor panel tokens while emitting per-panel UsageEvents before returning mapped DispatchFailure. On success, aggregates panel+judge tokens once, emits per-panel (attempt_kind: "panel") and per-judge (attempt_kind: "judge") UsageEvents with re-resolved sub-model identifiers, evaluates output guardrails on the synthesized response (emits guardrail_blocked on Block), and returns Success with telemetry_handled_by_stream suppression. Declares ensemble module in lib.rs.
End-to-end integration tests
crates/aisix-proxy/src/lib.rs
Adds test helpers (direct_model_entry, ensemble_model_entry, ensemble_model_entry_min, mount_panel_and_judge) and comprehensive integration tests: successful fan-out + judge synthesis with client receiving synthesized answer under ensemble model name and judge usage; rejection of ensemble requests containing tools (400); rejection of stream: true (400); output guardrail blocking judge with correct per-sub-call usage telemetry (panel members and judge, guardrail_blocked set); min_responses shortfall mapping to 502 with usage telemetry only for surviving calls; empty tools: [] and tool_choice: "none" allowance; forced tool_choice objects rejected (400); misconfigured judge error messages do not leak identifiers; non-chat endpoints (e.g., /v1/embeddings) reject ensemble with explicit error message; judge upstream 5xx collapses to 502 while billing panel members and emitting no judge usage when judge fails.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant dispatch as chat.rs<br/>dispatch
participant dispatch_ensemble as dispatch_ensemble
participant run_ensemble as run_ensemble
participant ProxyModelCaller as ProxyModelCaller
rect rgba(70, 130, 180, 0.5)
Note over dispatch,ProxyModelCaller: Panel fan-out
Client->>dispatch: POST /v1/chat/completions<br/>(ensemble model)
dispatch->>dispatch: is_ensemble() check
dispatch->>dispatch_ensemble: validate tools,<br/>stream, ensemble config
dispatch_ensemble->>run_ensemble: run_ensemble(req, config, caller)
run_ensemble->>ProxyModelCaller: call(panel_member, panel_request) ×N
ProxyModelCaller-->>run_ensemble: collect successes
run_ensemble->>run_ensemble: enforce<br/>min_responses_or_default()
end
rect rgba(60, 179, 113, 0.5)
Note over run_ensemble,ProxyModelCaller: Judge synthesis with retry
run_ensemble->>run_ensemble: build judge_request<br/>with labeled candidates
run_ensemble->>ProxyModelCaller: call_judge_with_retry(judge)
ProxyModelCaller-->>run_ensemble: EnsembleOutcome
end
rect rgba(220, 100, 60, 0.5)
Note over dispatch_ensemble,Client: Guardrails + telemetry
dispatch_ensemble->>dispatch_ensemble: emit UsageEvents<br/>per panel member & judge
dispatch_ensemble->>dispatch_ensemble: evaluate output guardrails
dispatch_ensemble-->>Client: synthesized ChatResponse<br/>(or 400/502/ContentFiltered)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

An independent audit of #604 found a client-facing leak, a CI-blocking
schema drift, and billing gaps on the ensemble error exits.
- Redact ensemble member identity from client errors: a misconfigured
judge's display_name + provider_key_id no longer reach error.message
(the resolve_provider_key path still forwarded it). Detail kept in logs.
- Regenerate ensemble/model schema JSON so the schema-drift CI gate
passes (the timeout_ms doc edit hadn't been regenerated).
- Bill the panel on every error exit, not just success. Panel members
that already round-tripped upstream now emit usage + commit tokens on
the insufficient-panel (502) and judge-failure paths, matching the
output-guardrail-block path. EnsembleError carries the surviving
PanelOutcomes.
- tools: [] (empty) no longer 400s; only a non-empty tools array or a
forcing tool_choice rejects (many SDKs always send tools: []).
- Non-chat endpoints reject an ensemble model with an explicit, accurate
400 (was incidental and said "routing"); added coverage.
- Remove dead EnsembleConfig::is_empty().
Tests: +6 e2e (judge-error redaction, 502-path + judge-failure panel
billing, tools:[] accepted, non-chat 400). 259 core + 439 proxy green;
clippy -D warnings clean; schema-drift gate clean.
Refs #601.
@moonming

Copy link
Copy Markdown
MemberAuthor

Post-PR independent audit — all findings resolved (917d692)

Per our merge-gate, an independent cold-review agent audited this PR. Findings and resolutions:

HIGH

  • Client-facing leak — a misconfigured judge's display_name + provider_key_id reached the client error.message (the earlier redaction only covered the get_by_name path; resolve_provider_key still forwarded it). Fixed — redacted; detail kept in server logs. Test: ensemble_misconfigured_judge_does_not_leak_internal_config.
  • Schema-drift CI would be red — the timeout_ms doc edit wasn't regenerated into the schema JSON. Fixed — regenerated; dump-schema + git diff --exit-code schemas/ clean.

MEDIUM

  • Billing under-report on error exits — panel members that already round-tripped upstream weren't billed on the insufficient-panel (502) path. Fixed — emit usage + commit tokens for the survivors on the 502 path, matching the output-guardrail-block path. Also extended to the judge-failure path (same class, surfaced during the fix). Tests: ensemble_insufficient_panel_returns_502, ensemble_judge_failure_still_bills_panel.
  • tools: [] wrongly 400'd — empty arrays (which many SDKs always send) were rejected. Fixed — only a non-empty tools or a forcing tool_choice rejects. Tests: ensemble_allows_empty_tools_array, ensemble_allows_tool_choice_none.
  • Non-chat rejection incidental + misleading — non-chat endpoints rejected ensemble models with a "routing" message and no test. Fixed — explicit, accurate 400. Test: ensemble_model_on_embeddings_returns_400_with_explicit_message.

Verified clean by the audit: the 3-way schema XOR (direct | routing | ensemble), single token-commit, double-emit suppression, response-body redaction, and the cross-repo wire contract vs api7/AISIX-Cloud#804.

Tracked follow-ups (not blocking this PR):

  • Total judge-prompt budget cap (per-candidate truncation exists; a panel-wide cap folds into the hardening PR under [DP] ensemble model — parallel panel fan-out + judge synthesis #601).
  • [CP] the dashboard should recognize the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion) — tracked under api7/AISIX-Cloud#804.

259 core + 439 proxy tests green; clippy --all-targets -- -D warnings clean; schema-drift gate clean.

@moonming
moonming merged commit ca2542e into mainJun 15, 2026
10 checks passed
@moonming
moonming deleted the feat/ensemble-model branch June 15, 2026 03:15
moonming added a commit that referenced this pull request Jun 15, 2026
Streams the judge's synthesized answer for ensemble models (stream:true). Panel buffers non-streaming, then the judge's tokens stream via the existing build_sse_stream (reused unmodified). Independently audited: safe to merge. Follows #604; tracked in #601.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DP] ensemble model — parallel panel fan-out + judge synthesis

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming) - #604

Merged
moonming merged 3 commits into
mainfrom
feat/ensemble-model
Jun 15, 2026
Merged

feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming)#604
moonming merged 3 commits into
mainfrom
feat/ensemble-model

Conversation

@moonming

@moonmingmoonming commented Jun 15, 2026

Copy link
Copy Markdown
Member

Summary

First slice of the ensemble virtual-model feature (DP side, tracked in #601; CP umbrella api7/AISIX-Cloud#804). An ensemble model fans one /v1/chat/completions request out to a panel of N upstream models in parallel, then a judge model synthesizes a single answer. It extends the existing virtual-model machinery: routing picks one target; ensemble calls all and synthesizes.

This PR lands the config layer and the non-streaming dispatch. Streaming, per-target rate-limiting, and hardening are separate follow-ups (see Deferred) — so this does notclose#601.

What's in this PR

1a89a6e — config layer (new ensemble model kind)

  • EnsembleConfig { panel: [{model, temperature?, seed?, weight?}], judge: {model, synthesis_prompt?}, min_responses?, timeout_ms? } in aisix-core.
  • Model.ensemble + Model::is_ensemble() (DP convention: virtual-model kind = presence of the block, same as routing).
  • Both DP schemas updated in lockstep so an ensemble row is never silently dropped on the kine watch path: the schemars-generated schemas/resources/model.schema.json (+ a standalone ensemble.schema.json) and the hand-written runtime validator models/schema.rs::model_schema(), whose oneOf is now a 3-way XOR (direct | routing | ensemble).

8375c85 — non-streaming fan-out dispatch

  • ensemble.rs: pure executor run_ensemble (parallel join_all fan-out, min_responses gate, per-member temperature/seed override for self-ensemble diversity, judge synthesis with neutral Answer 1..N labels + per-candidate truncation, judge retry-once-on-transient), plus the production ProxyModelCaller that resolves each member → ProviderKey → Bridge via the existing routing helpers.
  • chat.rs::dispatch_ensemble: branches at the post-rate-limit-reservation seam; rejects tools/tool_choice and stream:true with 400; runs the output guardrail on the synthesized answer; commits the aggregate panel+judge tokens once against the single entry-level reservation; emits one usage event per sub-call (attempt_kind = panel/judge, sharing request_id) before the guardrail check so a blocked response still bills the full fan-out; suppresses the entry-level usage event (no double-emit).
  • The client sees the ensemble's own model name; panel/judge model names never reach the client (synthesized answer, response.model, or error messages).

Behavior / contract

  • Config-driven: the operator curates the panel + judge; clients just call the model name. No per-request panel override.
  • Self-ensemble works with a single provider key (panel = the same model with per-member temperature).
  • v1 scope: chat-only; no server-side web retrieval; tools/streaming rejected (see Deferred).

Deferred (separate PRs, tracked in #601)

  • Streamingstream:true is 400-rejected here; there is no single-ChatResponse→SSE path today, so it's net-new.
  • Per-target rate-limiting — today only the entry model is debited; per-panel-member reservation is net-new (avoids N× provider-quota amplification).
  • Hardening — the response cache key must include the ensemble config; misc edges.
  • [CP] api7/AISIX-Cloud#804 — schema/projection/dashboard so operators can create ensemble models; the dashboard must also learn the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion).

Independent audit (merge-gate)

An independent cold-review agent audited this change. It confirmed: no double-emit / double-commit; authorization consistent with routing (panel/judge are internal targets — no escalation beyond what routing already allows); sound snapshot lifetimes across the fan-out; model names not leaked to the client response body. It caught and we fixed:

  • HIGH — on an output-guardrail block, the per-sub-call usage events were skipped while the panel tokens were already committed → cp-api under-reported a blocked-but-billed request. Fixed (emit before the guardrail check on both paths; charge: None on block) and locked with ensemble_output_block_still_emits_panel_and_judge_usage.
  • LOW — a misconfigured judge's model name leaked into the client-visible error.message → redacted (detail kept in server logs).
  • LOWtimeout_ms doc/behavior mismatch → behavior made uniform (now also applies to the judge call) + doc corrected.

Test plan

  • cargo test -p aisix-core259 pass (config types, runtime-validator 3-way XOR, schema round-trip; e.g. model_ensemble_with_direct_fields_fails, model_ensemble_with_routing_fails).
  • cargo test -p aisix-proxy433 pass: 8 executor unit tests + 5 handler e2e through a real OpenAiBridge against wiremock:
    • ensemble_fans_out_to_panel_and_returns_judge_synthesis (judge answer returned, model == "council", no upstream-id leak)
    • ensemble_rejects_tool_requests_with_400, ensemble_rejects_streaming_with_400
    • ensemble_insufficient_panel_returns_502
    • ensemble_output_block_still_emits_panel_and_judge_usage (HIGH regression lock — asserts 2 panel + 1 judge usage events still fire on a block)
  • cargo clippy --all-targets -- -D warnings — clean; full workspace builds.
  • CP-side e2e (create an ensemble model via the dashboard → call it end-to-end) lands with api7/AISIX-Cloud#804.

Summary by CodeRabbit

New Features

  • Added ensemble model support for chat completions, running multiple panel models in parallel and using a designated judge model to synthesize the final response.
  • Supports per-panel sampling overrides plus configurable minimum successful responses and optional per-member timeouts.
  • Enforces ensemble-only behavior in chat completions, with validation for streaming/tool fields and improved error handling.

Bug Fixes

  • Ensured schema generation and validation include the new ensemble configuration, with strict JSON field rejection and correct canonical schema outputs.

Introduce a third virtual-model kind alongside direct and routing: an
`ensemble` model fans a chat request out to a panel of models and
synthesizes their responses via a judge model. This lands the config
layer only; DP fan-out/synthesis dispatch is a follow-up.
- New EnsembleConfig/PanelMember/Judge types (models/ensemble.rs),
mirroring the routing config shape (deny_unknown_fields, _or_default
accessors, per-member temperature/seed for self-ensemble diversity).
- Model.ensemble field + Model::is_ensemble().
- Runtime validator (models/schema.rs): add the ensemble block and
widen the direct-vs-routing oneOf to a three-way XOR
(direct | routing | ensemble), so an ensemble row is neither silently
dropped on the kine watch path nor allowed to mix shapes.
- Regenerate schemas; emit a standalone ensemble.schema.json for CP
consumption (parity with routing.schema.json).
Refs #601, api7/AISIX-Cloud#804.
Wire the ensemble virtual-model kind end-to-end (non-streaming). One
/v1/chat/completions request to an `ensemble` model fans out to its panel
of models in parallel, then a judge model synthesizes a single answer.
- ensemble.rs: pure executor `run_ensemble` (parallel fan-out, min_responses
gate, per-member temperature/seed for self-ensemble diversity, judge
synthesis with neutral candidate labels + per-candidate truncation, judge
retry-once-on-transient), plus the production `ProxyModelCaller` that
resolves each member display_name -> ProviderKey -> Bridge.
- chat.rs: `dispatch_ensemble` branch (after the entry-level rate-limit
reservation, before the failover/streaming machinery): rejects tools +
streaming with 400; runs the output guardrail on the synthesized answer;
commits the aggregate panel+judge tokens once; emits one usage event per
sub-call (attempt_kind panel/judge) sharing request_id, before the
guardrail check so a blocked response still bills the full fan-out;
suppresses the entry-level usage event to avoid double-emit.
- Client sees the ensemble's own model name; panel/judge model names never
reach the client (synthesized answer, response.model, or error messages).
- Tests: 8 executor unit tests + 5 handler e2e (fan-out->judge, tools/stream
400, insufficient-panel->502, output-block-still-bills-panel+judge).
Streaming is rejected for now (no single-response->SSE path); it lands in a
follow-up. Dashboard needs to recognize the new attempt_kind values
"panel"/"judge" (free-string on the wire; tracked CP-side).
Refs #601, api7/AISIX-Cloud#804.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5a349dc8-7f5e-4ad0-bc1f-8f3c51557012

📥 Commits

Reviewing files that changed from the base of the PR and between 8375c85 and 917d692.

📒 Files selected for processing (7)
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/ensemble.rs
  • crates/aisix-proxy/src/lib.rs
  • schemas/resources/ensemble.schema.json
  • schemas/resources/model.schema.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • schemas/resources/ensemble.schema.json
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/ensemble.rs

📝 Walkthrough

Walkthrough

Adds "ensemble" model support across the proxy stack: new PanelMember, Judge, and EnsembleConfig Rust types in aisix-core, an optional ensemble field on Model, updated Admin API JSON schema validation with mutual exclusivity rules, a new run_ensemble orchestration engine in aisix-proxy that fans out to panel members concurrently and uses a judge model to synthesize responses, and a dispatch_ensemble path in the chat dispatch loop.

Changes

Ensemble Model Feature

Layer / File(s)Summary
Ensemble data model types and module wiring
crates/aisix-core/src/models/ensemble.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/models/model.rs
Defines PanelMember, Judge, and EnsembleConfig structs with deny_unknown_fields, helper methods (min_responses_or_default(), timeout()), and convenience constructors. Adds optional ensemble: Option<EnsembleConfig> field and is_ensemble() method to Model. Re-exports all three types from the models module. Unit tests cover deserialization, clamping to panel size, sentinel handling for timeout_ms=0, rejection of unknown fields, and constructor behavior.
JSON schema definitions and Admin API validation
crates/aisix-core/src/models/schema.rs, schemas/resources/ensemble.schema.json, schemas/resources/model.schema.json, crates/aisix-core/src/bin/dump-schema.rs
Adds ensemble object schema inside model_schema() with required panel/judge structure and field constraints. Updates oneOf XOR rules so ensemble is mutually exclusive with routing, direct upstream fields, background_model_check, and cooldown. Introduces standalone ensemble.schema.json and extends model.schema.json with EnsembleConfig/Judge/PanelMember definitions under additionalProperties: false. Registers EnsembleConfig in the dump-schema binary. Schema tests cover valid payloads (including with top-level allowed_cidrs and rate_limit) and failure cases for mutual exclusivity and malformed panel structure.
Ensemble orchestration engine
crates/aisix-proxy/src/ensemble.rs
Introduces ModelCaller trait and ProxyModelCaller (resolves models via AisixSnapshot, builds BridgeContext with optional deadline, dispatches via bridge.chat). Implements run_ensemble that concurrently fans out to panel members applying per-member temperature/seed overrides and forcing non-streaming, enforces min_responses_or_default(), builds a labeled judge prompt (with per-candidate truncation at UTF-8 boundaries), and calls the judge via call_judge_with_retry with transient-error classification. Defines PanelOutcome, EnsembleOutcome, and EnsembleError with http_status() (panel exhaustion → 502; judge failures delegated). Unit tests cover fan-out, insufficient panel, partial success when minimum is met, judge retry semantics (once on transient, not on non-transient), per-member temperature overrides vs inherited request temperature, and prompt privacy (no panel model-name leakage).
Chat dispatch integration
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/dispatch.rs, crates/aisix-proxy/src/lib.rs
Skips single-target provider/bridge preflight validation for ensemble models via a new guard in require_provider. In dispatch, adds an early is_ensemble() branch that routes to a new dispatch_ensemble function before streaming/failover routing. dispatch_ensemble rejects ensemble requests containing tools or forcing tool_choice with 400, rejects stream: true requests, runs run_ensemble, and on error commits survivor panel tokens while emitting per-panel UsageEvents before returning mapped DispatchFailure. On success, aggregates panel+judge tokens once, emits per-panel (attempt_kind: "panel") and per-judge (attempt_kind: "judge") UsageEvents with re-resolved sub-model identifiers, evaluates output guardrails on the synthesized response (emits guardrail_blocked on Block), and returns Success with telemetry_handled_by_stream suppression. Declares ensemble module in lib.rs.
End-to-end integration tests
crates/aisix-proxy/src/lib.rs
Adds test helpers (direct_model_entry, ensemble_model_entry, ensemble_model_entry_min, mount_panel_and_judge) and comprehensive integration tests: successful fan-out + judge synthesis with client receiving synthesized answer under ensemble model name and judge usage; rejection of ensemble requests containing tools (400); rejection of stream: true (400); output guardrail blocking judge with correct per-sub-call usage telemetry (panel members and judge, guardrail_blocked set); min_responses shortfall mapping to 502 with usage telemetry only for surviving calls; empty tools: [] and tool_choice: "none" allowance; forced tool_choice objects rejected (400); misconfigured judge error messages do not leak identifiers; non-chat endpoints (e.g., /v1/embeddings) reject ensemble with explicit error message; judge upstream 5xx collapses to 502 while billing panel members and emitting no judge usage when judge fails.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant dispatch as chat.rs<br/>dispatch
participant dispatch_ensemble as dispatch_ensemble
participant run_ensemble as run_ensemble
participant ProxyModelCaller as ProxyModelCaller
rect rgba(70, 130, 180, 0.5)
Note over dispatch,ProxyModelCaller: Panel fan-out
Client->>dispatch: POST /v1/chat/completions<br/>(ensemble model)
dispatch->>dispatch: is_ensemble() check
dispatch->>dispatch_ensemble: validate tools,<br/>stream, ensemble config
dispatch_ensemble->>run_ensemble: run_ensemble(req, config, caller)
run_ensemble->>ProxyModelCaller: call(panel_member, panel_request) ×N
ProxyModelCaller-->>run_ensemble: collect successes
run_ensemble->>run_ensemble: enforce<br/>min_responses_or_default()
end
rect rgba(60, 179, 113, 0.5)
Note over run_ensemble,ProxyModelCaller: Judge synthesis with retry
run_ensemble->>run_ensemble: build judge_request<br/>with labeled candidates
run_ensemble->>ProxyModelCaller: call_judge_with_retry(judge)
ProxyModelCaller-->>run_ensemble: EnsembleOutcome
end
rect rgba(220, 100, 60, 0.5)
Note over dispatch_ensemble,Client: Guardrails + telemetry
dispatch_ensemble->>dispatch_ensemble: emit UsageEvents<br/>per panel member & judge
dispatch_ensemble->>dispatch_ensemble: evaluate output guardrails
dispatch_ensemble-->>Client: synthesized ChatResponse<br/>(or 400/502/ContentFiltered)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

An independent audit of #604 found a client-facing leak, a CI-blocking
schema drift, and billing gaps on the ensemble error exits.
- Redact ensemble member identity from client errors: a misconfigured
judge's display_name + provider_key_id no longer reach error.message
(the resolve_provider_key path still forwarded it). Detail kept in logs.
- Regenerate ensemble/model schema JSON so the schema-drift CI gate
passes (the timeout_ms doc edit hadn't been regenerated).
- Bill the panel on every error exit, not just success. Panel members
that already round-tripped upstream now emit usage + commit tokens on
the insufficient-panel (502) and judge-failure paths, matching the
output-guardrail-block path. EnsembleError carries the surviving
PanelOutcomes.
- tools: [] (empty) no longer 400s; only a non-empty tools array or a
forcing tool_choice rejects (many SDKs always send tools: []).
- Non-chat endpoints reject an ensemble model with an explicit, accurate
400 (was incidental and said "routing"); added coverage.
- Remove dead EnsembleConfig::is_empty().
Tests: +6 e2e (judge-error redaction, 502-path + judge-failure panel
billing, tools:[] accepted, non-chat 400). 259 core + 439 proxy green;
clippy -D warnings clean; schema-drift gate clean.
Refs #601.
@moonming

Copy link
Copy Markdown
MemberAuthor

Post-PR independent audit — all findings resolved (917d692)

Per our merge-gate, an independent cold-review agent audited this PR. Findings and resolutions:

HIGH

  • Client-facing leak — a misconfigured judge's display_name + provider_key_id reached the client error.message (the earlier redaction only covered the get_by_name path; resolve_provider_key still forwarded it). Fixed — redacted; detail kept in server logs. Test: ensemble_misconfigured_judge_does_not_leak_internal_config.
  • Schema-drift CI would be red — the timeout_ms doc edit wasn't regenerated into the schema JSON. Fixed — regenerated; dump-schema + git diff --exit-code schemas/ clean.

MEDIUM

  • Billing under-report on error exits — panel members that already round-tripped upstream weren't billed on the insufficient-panel (502) path. Fixed — emit usage + commit tokens for the survivors on the 502 path, matching the output-guardrail-block path. Also extended to the judge-failure path (same class, surfaced during the fix). Tests: ensemble_insufficient_panel_returns_502, ensemble_judge_failure_still_bills_panel.
  • tools: [] wrongly 400'd — empty arrays (which many SDKs always send) were rejected. Fixed — only a non-empty tools or a forcing tool_choice rejects. Tests: ensemble_allows_empty_tools_array, ensemble_allows_tool_choice_none.
  • Non-chat rejection incidental + misleading — non-chat endpoints rejected ensemble models with a "routing" message and no test. Fixed — explicit, accurate 400. Test: ensemble_model_on_embeddings_returns_400_with_explicit_message.

Verified clean by the audit: the 3-way schema XOR (direct | routing | ensemble), single token-commit, double-emit suppression, response-body redaction, and the cross-repo wire contract vs api7/AISIX-Cloud#804.

Tracked follow-ups (not blocking this PR):

  • Total judge-prompt budget cap (per-candidate truncation exists; a panel-wide cap folds into the hardening PR under [DP] ensemble model — parallel panel fan-out + judge synthesis #601).
  • [CP] the dashboard should recognize the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion) — tracked under api7/AISIX-Cloud#804.

259 core + 439 proxy tests green; clippy --all-targets -- -D warnings clean; schema-drift gate clean.

@moonming
moonming merged commit ca2542e into mainJun 15, 2026
10 checks passed
@moonming
moonming deleted the feat/ensemble-model branch June 15, 2026 03:15
moonming added a commit that referenced this pull request Jun 15, 2026
Streams the judge's synthesized answer for ensemble models (stream:true). Panel buffers non-streaming, then the judge's tokens stream via the existing build_sse_stream (reused unmodified). Independently audited: safe to merge. Follows #604; tracked in #601.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DP] ensemble model — parallel panel fan-out + judge synthesis

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming) - #604

Merged
moonming merged 3 commits into
mainfrom
feat/ensemble-model
Jun 15, 2026
Merged

feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming)#604
moonming merged 3 commits into
mainfrom
feat/ensemble-model

Conversation

@moonming

@moonmingmoonming commented Jun 15, 2026

Copy link
Copy Markdown
Member

Summary

First slice of the ensemble virtual-model feature (DP side, tracked in #601; CP umbrella api7/AISIX-Cloud#804). An ensemble model fans one /v1/chat/completions request out to a panel of N upstream models in parallel, then a judge model synthesizes a single answer. It extends the existing virtual-model machinery: routing picks one target; ensemble calls all and synthesizes.

This PR lands the config layer and the non-streaming dispatch. Streaming, per-target rate-limiting, and hardening are separate follow-ups (see Deferred) — so this does notclose#601.

What's in this PR

1a89a6e — config layer (new ensemble model kind)

  • EnsembleConfig { panel: [{model, temperature?, seed?, weight?}], judge: {model, synthesis_prompt?}, min_responses?, timeout_ms? } in aisix-core.
  • Model.ensemble + Model::is_ensemble() (DP convention: virtual-model kind = presence of the block, same as routing).
  • Both DP schemas updated in lockstep so an ensemble row is never silently dropped on the kine watch path: the schemars-generated schemas/resources/model.schema.json (+ a standalone ensemble.schema.json) and the hand-written runtime validator models/schema.rs::model_schema(), whose oneOf is now a 3-way XOR (direct | routing | ensemble).

8375c85 — non-streaming fan-out dispatch

  • ensemble.rs: pure executor run_ensemble (parallel join_all fan-out, min_responses gate, per-member temperature/seed override for self-ensemble diversity, judge synthesis with neutral Answer 1..N labels + per-candidate truncation, judge retry-once-on-transient), plus the production ProxyModelCaller that resolves each member → ProviderKey → Bridge via the existing routing helpers.
  • chat.rs::dispatch_ensemble: branches at the post-rate-limit-reservation seam; rejects tools/tool_choice and stream:true with 400; runs the output guardrail on the synthesized answer; commits the aggregate panel+judge tokens once against the single entry-level reservation; emits one usage event per sub-call (attempt_kind = panel/judge, sharing request_id) before the guardrail check so a blocked response still bills the full fan-out; suppresses the entry-level usage event (no double-emit).
  • The client sees the ensemble's own model name; panel/judge model names never reach the client (synthesized answer, response.model, or error messages).

Behavior / contract

  • Config-driven: the operator curates the panel + judge; clients just call the model name. No per-request panel override.
  • Self-ensemble works with a single provider key (panel = the same model with per-member temperature).
  • v1 scope: chat-only; no server-side web retrieval; tools/streaming rejected (see Deferred).

Deferred (separate PRs, tracked in #601)

  • Streamingstream:true is 400-rejected here; there is no single-ChatResponse→SSE path today, so it's net-new.
  • Per-target rate-limiting — today only the entry model is debited; per-panel-member reservation is net-new (avoids N× provider-quota amplification).
  • Hardening — the response cache key must include the ensemble config; misc edges.
  • [CP] api7/AISIX-Cloud#804 — schema/projection/dashboard so operators can create ensemble models; the dashboard must also learn the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion).

Independent audit (merge-gate)

An independent cold-review agent audited this change. It confirmed: no double-emit / double-commit; authorization consistent with routing (panel/judge are internal targets — no escalation beyond what routing already allows); sound snapshot lifetimes across the fan-out; model names not leaked to the client response body. It caught and we fixed:

  • HIGH — on an output-guardrail block, the per-sub-call usage events were skipped while the panel tokens were already committed → cp-api under-reported a blocked-but-billed request. Fixed (emit before the guardrail check on both paths; charge: None on block) and locked with ensemble_output_block_still_emits_panel_and_judge_usage.
  • LOW — a misconfigured judge's model name leaked into the client-visible error.message → redacted (detail kept in server logs).
  • LOWtimeout_ms doc/behavior mismatch → behavior made uniform (now also applies to the judge call) + doc corrected.

Test plan

  • cargo test -p aisix-core259 pass (config types, runtime-validator 3-way XOR, schema round-trip; e.g. model_ensemble_with_direct_fields_fails, model_ensemble_with_routing_fails).
  • cargo test -p aisix-proxy433 pass: 8 executor unit tests + 5 handler e2e through a real OpenAiBridge against wiremock:
    • ensemble_fans_out_to_panel_and_returns_judge_synthesis (judge answer returned, model == "council", no upstream-id leak)
    • ensemble_rejects_tool_requests_with_400, ensemble_rejects_streaming_with_400
    • ensemble_insufficient_panel_returns_502
    • ensemble_output_block_still_emits_panel_and_judge_usage (HIGH regression lock — asserts 2 panel + 1 judge usage events still fire on a block)
  • cargo clippy --all-targets -- -D warnings — clean; full workspace builds.
  • CP-side e2e (create an ensemble model via the dashboard → call it end-to-end) lands with api7/AISIX-Cloud#804.

Summary by CodeRabbit

New Features

  • Added ensemble model support for chat completions, running multiple panel models in parallel and using a designated judge model to synthesize the final response.
  • Supports per-panel sampling overrides plus configurable minimum successful responses and optional per-member timeouts.
  • Enforces ensemble-only behavior in chat completions, with validation for streaming/tool fields and improved error handling.

Bug Fixes

  • Ensured schema generation and validation include the new ensemble configuration, with strict JSON field rejection and correct canonical schema outputs.

Introduce a third virtual-model kind alongside direct and routing: an
`ensemble` model fans a chat request out to a panel of models and
synthesizes their responses via a judge model. This lands the config
layer only; DP fan-out/synthesis dispatch is a follow-up.
- New EnsembleConfig/PanelMember/Judge types (models/ensemble.rs),
mirroring the routing config shape (deny_unknown_fields, _or_default
accessors, per-member temperature/seed for self-ensemble diversity).
- Model.ensemble field + Model::is_ensemble().
- Runtime validator (models/schema.rs): add the ensemble block and
widen the direct-vs-routing oneOf to a three-way XOR
(direct | routing | ensemble), so an ensemble row is neither silently
dropped on the kine watch path nor allowed to mix shapes.
- Regenerate schemas; emit a standalone ensemble.schema.json for CP
consumption (parity with routing.schema.json).
Refs #601, api7/AISIX-Cloud#804.
Wire the ensemble virtual-model kind end-to-end (non-streaming). One
/v1/chat/completions request to an `ensemble` model fans out to its panel
of models in parallel, then a judge model synthesizes a single answer.
- ensemble.rs: pure executor `run_ensemble` (parallel fan-out, min_responses
gate, per-member temperature/seed for self-ensemble diversity, judge
synthesis with neutral candidate labels + per-candidate truncation, judge
retry-once-on-transient), plus the production `ProxyModelCaller` that
resolves each member display_name -> ProviderKey -> Bridge.
- chat.rs: `dispatch_ensemble` branch (after the entry-level rate-limit
reservation, before the failover/streaming machinery): rejects tools +
streaming with 400; runs the output guardrail on the synthesized answer;
commits the aggregate panel+judge tokens once; emits one usage event per
sub-call (attempt_kind panel/judge) sharing request_id, before the
guardrail check so a blocked response still bills the full fan-out;
suppresses the entry-level usage event to avoid double-emit.
- Client sees the ensemble's own model name; panel/judge model names never
reach the client (synthesized answer, response.model, or error messages).
- Tests: 8 executor unit tests + 5 handler e2e (fan-out->judge, tools/stream
400, insufficient-panel->502, output-block-still-bills-panel+judge).
Streaming is rejected for now (no single-response->SSE path); it lands in a
follow-up. Dashboard needs to recognize the new attempt_kind values
"panel"/"judge" (free-string on the wire; tracked CP-side).
Refs #601, api7/AISIX-Cloud#804.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5a349dc8-7f5e-4ad0-bc1f-8f3c51557012

📥 Commits

Reviewing files that changed from the base of the PR and between 8375c85 and 917d692.

📒 Files selected for processing (7)
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/ensemble.rs
  • crates/aisix-proxy/src/lib.rs
  • schemas/resources/ensemble.schema.json
  • schemas/resources/model.schema.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • schemas/resources/ensemble.schema.json
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/ensemble.rs

📝 Walkthrough

Walkthrough

Adds "ensemble" model support across the proxy stack: new PanelMember, Judge, and EnsembleConfig Rust types in aisix-core, an optional ensemble field on Model, updated Admin API JSON schema validation with mutual exclusivity rules, a new run_ensemble orchestration engine in aisix-proxy that fans out to panel members concurrently and uses a judge model to synthesize responses, and a dispatch_ensemble path in the chat dispatch loop.

Changes

Ensemble Model Feature

Layer / File(s)Summary
Ensemble data model types and module wiring
crates/aisix-core/src/models/ensemble.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/models/model.rs
Defines PanelMember, Judge, and EnsembleConfig structs with deny_unknown_fields, helper methods (min_responses_or_default(), timeout()), and convenience constructors. Adds optional ensemble: Option<EnsembleConfig> field and is_ensemble() method to Model. Re-exports all three types from the models module. Unit tests cover deserialization, clamping to panel size, sentinel handling for timeout_ms=0, rejection of unknown fields, and constructor behavior.
JSON schema definitions and Admin API validation
crates/aisix-core/src/models/schema.rs, schemas/resources/ensemble.schema.json, schemas/resources/model.schema.json, crates/aisix-core/src/bin/dump-schema.rs
Adds ensemble object schema inside model_schema() with required panel/judge structure and field constraints. Updates oneOf XOR rules so ensemble is mutually exclusive with routing, direct upstream fields, background_model_check, and cooldown. Introduces standalone ensemble.schema.json and extends model.schema.json with EnsembleConfig/Judge/PanelMember definitions under additionalProperties: false. Registers EnsembleConfig in the dump-schema binary. Schema tests cover valid payloads (including with top-level allowed_cidrs and rate_limit) and failure cases for mutual exclusivity and malformed panel structure.
Ensemble orchestration engine
crates/aisix-proxy/src/ensemble.rs
Introduces ModelCaller trait and ProxyModelCaller (resolves models via AisixSnapshot, builds BridgeContext with optional deadline, dispatches via bridge.chat). Implements run_ensemble that concurrently fans out to panel members applying per-member temperature/seed overrides and forcing non-streaming, enforces min_responses_or_default(), builds a labeled judge prompt (with per-candidate truncation at UTF-8 boundaries), and calls the judge via call_judge_with_retry with transient-error classification. Defines PanelOutcome, EnsembleOutcome, and EnsembleError with http_status() (panel exhaustion → 502; judge failures delegated). Unit tests cover fan-out, insufficient panel, partial success when minimum is met, judge retry semantics (once on transient, not on non-transient), per-member temperature overrides vs inherited request temperature, and prompt privacy (no panel model-name leakage).
Chat dispatch integration
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/dispatch.rs, crates/aisix-proxy/src/lib.rs
Skips single-target provider/bridge preflight validation for ensemble models via a new guard in require_provider. In dispatch, adds an early is_ensemble() branch that routes to a new dispatch_ensemble function before streaming/failover routing. dispatch_ensemble rejects ensemble requests containing tools or forcing tool_choice with 400, rejects stream: true requests, runs run_ensemble, and on error commits survivor panel tokens while emitting per-panel UsageEvents before returning mapped DispatchFailure. On success, aggregates panel+judge tokens once, emits per-panel (attempt_kind: "panel") and per-judge (attempt_kind: "judge") UsageEvents with re-resolved sub-model identifiers, evaluates output guardrails on the synthesized response (emits guardrail_blocked on Block), and returns Success with telemetry_handled_by_stream suppression. Declares ensemble module in lib.rs.
End-to-end integration tests
crates/aisix-proxy/src/lib.rs
Adds test helpers (direct_model_entry, ensemble_model_entry, ensemble_model_entry_min, mount_panel_and_judge) and comprehensive integration tests: successful fan-out + judge synthesis with client receiving synthesized answer under ensemble model name and judge usage; rejection of ensemble requests containing tools (400); rejection of stream: true (400); output guardrail blocking judge with correct per-sub-call usage telemetry (panel members and judge, guardrail_blocked set); min_responses shortfall mapping to 502 with usage telemetry only for surviving calls; empty tools: [] and tool_choice: "none" allowance; forced tool_choice objects rejected (400); misconfigured judge error messages do not leak identifiers; non-chat endpoints (e.g., /v1/embeddings) reject ensemble with explicit error message; judge upstream 5xx collapses to 502 while billing panel members and emitting no judge usage when judge fails.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant dispatch as chat.rs<br/>dispatch
participant dispatch_ensemble as dispatch_ensemble
participant run_ensemble as run_ensemble
participant ProxyModelCaller as ProxyModelCaller
rect rgba(70, 130, 180, 0.5)
Note over dispatch,ProxyModelCaller: Panel fan-out
Client->>dispatch: POST /v1/chat/completions<br/>(ensemble model)
dispatch->>dispatch: is_ensemble() check
dispatch->>dispatch_ensemble: validate tools,<br/>stream, ensemble config
dispatch_ensemble->>run_ensemble: run_ensemble(req, config, caller)
run_ensemble->>ProxyModelCaller: call(panel_member, panel_request) ×N
ProxyModelCaller-->>run_ensemble: collect successes
run_ensemble->>run_ensemble: enforce<br/>min_responses_or_default()
end
rect rgba(60, 179, 113, 0.5)
Note over run_ensemble,ProxyModelCaller: Judge synthesis with retry
run_ensemble->>run_ensemble: build judge_request<br/>with labeled candidates
run_ensemble->>ProxyModelCaller: call_judge_with_retry(judge)
ProxyModelCaller-->>run_ensemble: EnsembleOutcome
end
rect rgba(220, 100, 60, 0.5)
Note over dispatch_ensemble,Client: Guardrails + telemetry
dispatch_ensemble->>dispatch_ensemble: emit UsageEvents<br/>per panel member & judge
dispatch_ensemble->>dispatch_ensemble: evaluate output guardrails
dispatch_ensemble-->>Client: synthesized ChatResponse<br/>(or 400/502/ContentFiltered)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

An independent audit of #604 found a client-facing leak, a CI-blocking
schema drift, and billing gaps on the ensemble error exits.
- Redact ensemble member identity from client errors: a misconfigured
judge's display_name + provider_key_id no longer reach error.message
(the resolve_provider_key path still forwarded it). Detail kept in logs.
- Regenerate ensemble/model schema JSON so the schema-drift CI gate
passes (the timeout_ms doc edit hadn't been regenerated).
- Bill the panel on every error exit, not just success. Panel members
that already round-tripped upstream now emit usage + commit tokens on
the insufficient-panel (502) and judge-failure paths, matching the
output-guardrail-block path. EnsembleError carries the surviving
PanelOutcomes.
- tools: [] (empty) no longer 400s; only a non-empty tools array or a
forcing tool_choice rejects (many SDKs always send tools: []).
- Non-chat endpoints reject an ensemble model with an explicit, accurate
400 (was incidental and said "routing"); added coverage.
- Remove dead EnsembleConfig::is_empty().
Tests: +6 e2e (judge-error redaction, 502-path + judge-failure panel
billing, tools:[] accepted, non-chat 400). 259 core + 439 proxy green;
clippy -D warnings clean; schema-drift gate clean.
Refs #601.
@moonming

Copy link
Copy Markdown
MemberAuthor

Post-PR independent audit — all findings resolved (917d692)

Per our merge-gate, an independent cold-review agent audited this PR. Findings and resolutions:

HIGH

  • Client-facing leak — a misconfigured judge's display_name + provider_key_id reached the client error.message (the earlier redaction only covered the get_by_name path; resolve_provider_key still forwarded it). Fixed — redacted; detail kept in server logs. Test: ensemble_misconfigured_judge_does_not_leak_internal_config.
  • Schema-drift CI would be red — the timeout_ms doc edit wasn't regenerated into the schema JSON. Fixed — regenerated; dump-schema + git diff --exit-code schemas/ clean.

MEDIUM

  • Billing under-report on error exits — panel members that already round-tripped upstream weren't billed on the insufficient-panel (502) path. Fixed — emit usage + commit tokens for the survivors on the 502 path, matching the output-guardrail-block path. Also extended to the judge-failure path (same class, surfaced during the fix). Tests: ensemble_insufficient_panel_returns_502, ensemble_judge_failure_still_bills_panel.
  • tools: [] wrongly 400'd — empty arrays (which many SDKs always send) were rejected. Fixed — only a non-empty tools or a forcing tool_choice rejects. Tests: ensemble_allows_empty_tools_array, ensemble_allows_tool_choice_none.
  • Non-chat rejection incidental + misleading — non-chat endpoints rejected ensemble models with a "routing" message and no test. Fixed — explicit, accurate 400. Test: ensemble_model_on_embeddings_returns_400_with_explicit_message.

Verified clean by the audit: the 3-way schema XOR (direct | routing | ensemble), single token-commit, double-emit suppression, response-body redaction, and the cross-repo wire contract vs api7/AISIX-Cloud#804.

Tracked follow-ups (not blocking this PR):

  • Total judge-prompt budget cap (per-candidate truncation exists; a panel-wide cap folds into the hardening PR under [DP] ensemble model — parallel panel fan-out + judge synthesis #601).
  • [CP] the dashboard should recognize the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion) — tracked under api7/AISIX-Cloud#804.

259 core + 439 proxy tests green; clippy --all-targets -- -D warnings clean; schema-drift gate clean.

@moonming
moonming merged commit ca2542e into mainJun 15, 2026
10 checks passed
@moonming
moonming deleted the feat/ensemble-model branch June 15, 2026 03:15
moonming added a commit that referenced this pull request Jun 15, 2026
Streams the judge's synthesized answer for ensemble models (stream:true). Panel buffers non-streaming, then the judge's tokens stream via the existing build_sse_stream (reused unmodified). Independently audited: safe to merge. Follows #604; tracked in #601.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DP] ensemble model — parallel panel fan-out + judge synthesis

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming) - #604

Merged
moonming merged 3 commits into
mainfrom
feat/ensemble-model
Jun 15, 2026
Merged

feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming)#604
moonming merged 3 commits into
mainfrom
feat/ensemble-model

Conversation

@moonming

@moonmingmoonming commented Jun 15, 2026

Copy link
Copy Markdown
Member

Summary

First slice of the ensemble virtual-model feature (DP side, tracked in #601; CP umbrella api7/AISIX-Cloud#804). An ensemble model fans one /v1/chat/completions request out to a panel of N upstream models in parallel, then a judge model synthesizes a single answer. It extends the existing virtual-model machinery: routing picks one target; ensemble calls all and synthesizes.

This PR lands the config layer and the non-streaming dispatch. Streaming, per-target rate-limiting, and hardening are separate follow-ups (see Deferred) — so this does notclose#601.

What's in this PR

1a89a6e — config layer (new ensemble model kind)

  • EnsembleConfig { panel: [{model, temperature?, seed?, weight?}], judge: {model, synthesis_prompt?}, min_responses?, timeout_ms? } in aisix-core.
  • Model.ensemble + Model::is_ensemble() (DP convention: virtual-model kind = presence of the block, same as routing).
  • Both DP schemas updated in lockstep so an ensemble row is never silently dropped on the kine watch path: the schemars-generated schemas/resources/model.schema.json (+ a standalone ensemble.schema.json) and the hand-written runtime validator models/schema.rs::model_schema(), whose oneOf is now a 3-way XOR (direct | routing | ensemble).

8375c85 — non-streaming fan-out dispatch

  • ensemble.rs: pure executor run_ensemble (parallel join_all fan-out, min_responses gate, per-member temperature/seed override for self-ensemble diversity, judge synthesis with neutral Answer 1..N labels + per-candidate truncation, judge retry-once-on-transient), plus the production ProxyModelCaller that resolves each member → ProviderKey → Bridge via the existing routing helpers.
  • chat.rs::dispatch_ensemble: branches at the post-rate-limit-reservation seam; rejects tools/tool_choice and stream:true with 400; runs the output guardrail on the synthesized answer; commits the aggregate panel+judge tokens once against the single entry-level reservation; emits one usage event per sub-call (attempt_kind = panel/judge, sharing request_id) before the guardrail check so a blocked response still bills the full fan-out; suppresses the entry-level usage event (no double-emit).
  • The client sees the ensemble's own model name; panel/judge model names never reach the client (synthesized answer, response.model, or error messages).

Behavior / contract

  • Config-driven: the operator curates the panel + judge; clients just call the model name. No per-request panel override.
  • Self-ensemble works with a single provider key (panel = the same model with per-member temperature).
  • v1 scope: chat-only; no server-side web retrieval; tools/streaming rejected (see Deferred).

Deferred (separate PRs, tracked in #601)

  • Streamingstream:true is 400-rejected here; there is no single-ChatResponse→SSE path today, so it's net-new.
  • Per-target rate-limiting — today only the entry model is debited; per-panel-member reservation is net-new (avoids N× provider-quota amplification).
  • Hardening — the response cache key must include the ensemble config; misc edges.
  • [CP] api7/AISIX-Cloud#804 — schema/projection/dashboard so operators can create ensemble models; the dashboard must also learn the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion).

Independent audit (merge-gate)

An independent cold-review agent audited this change. It confirmed: no double-emit / double-commit; authorization consistent with routing (panel/judge are internal targets — no escalation beyond what routing already allows); sound snapshot lifetimes across the fan-out; model names not leaked to the client response body. It caught and we fixed:

  • HIGH — on an output-guardrail block, the per-sub-call usage events were skipped while the panel tokens were already committed → cp-api under-reported a blocked-but-billed request. Fixed (emit before the guardrail check on both paths; charge: None on block) and locked with ensemble_output_block_still_emits_panel_and_judge_usage.
  • LOW — a misconfigured judge's model name leaked into the client-visible error.message → redacted (detail kept in server logs).
  • LOWtimeout_ms doc/behavior mismatch → behavior made uniform (now also applies to the judge call) + doc corrected.

Test plan

  • cargo test -p aisix-core259 pass (config types, runtime-validator 3-way XOR, schema round-trip; e.g. model_ensemble_with_direct_fields_fails, model_ensemble_with_routing_fails).
  • cargo test -p aisix-proxy433 pass: 8 executor unit tests + 5 handler e2e through a real OpenAiBridge against wiremock:
    • ensemble_fans_out_to_panel_and_returns_judge_synthesis (judge answer returned, model == "council", no upstream-id leak)
    • ensemble_rejects_tool_requests_with_400, ensemble_rejects_streaming_with_400
    • ensemble_insufficient_panel_returns_502
    • ensemble_output_block_still_emits_panel_and_judge_usage (HIGH regression lock — asserts 2 panel + 1 judge usage events still fire on a block)
  • cargo clippy --all-targets -- -D warnings — clean; full workspace builds.
  • CP-side e2e (create an ensemble model via the dashboard → call it end-to-end) lands with api7/AISIX-Cloud#804.

Summary by CodeRabbit

New Features

  • Added ensemble model support for chat completions, running multiple panel models in parallel and using a designated judge model to synthesize the final response.
  • Supports per-panel sampling overrides plus configurable minimum successful responses and optional per-member timeouts.
  • Enforces ensemble-only behavior in chat completions, with validation for streaming/tool fields and improved error handling.

Bug Fixes

  • Ensured schema generation and validation include the new ensemble configuration, with strict JSON field rejection and correct canonical schema outputs.

Introduce a third virtual-model kind alongside direct and routing: an
`ensemble` model fans a chat request out to a panel of models and
synthesizes their responses via a judge model. This lands the config
layer only; DP fan-out/synthesis dispatch is a follow-up.
- New EnsembleConfig/PanelMember/Judge types (models/ensemble.rs),
mirroring the routing config shape (deny_unknown_fields, _or_default
accessors, per-member temperature/seed for self-ensemble diversity).
- Model.ensemble field + Model::is_ensemble().
- Runtime validator (models/schema.rs): add the ensemble block and
widen the direct-vs-routing oneOf to a three-way XOR
(direct | routing | ensemble), so an ensemble row is neither silently
dropped on the kine watch path nor allowed to mix shapes.
- Regenerate schemas; emit a standalone ensemble.schema.json for CP
consumption (parity with routing.schema.json).
Refs #601, api7/AISIX-Cloud#804.
Wire the ensemble virtual-model kind end-to-end (non-streaming). One
/v1/chat/completions request to an `ensemble` model fans out to its panel
of models in parallel, then a judge model synthesizes a single answer.
- ensemble.rs: pure executor `run_ensemble` (parallel fan-out, min_responses
gate, per-member temperature/seed for self-ensemble diversity, judge
synthesis with neutral candidate labels + per-candidate truncation, judge
retry-once-on-transient), plus the production `ProxyModelCaller` that
resolves each member display_name -> ProviderKey -> Bridge.
- chat.rs: `dispatch_ensemble` branch (after the entry-level rate-limit
reservation, before the failover/streaming machinery): rejects tools +
streaming with 400; runs the output guardrail on the synthesized answer;
commits the aggregate panel+judge tokens once; emits one usage event per
sub-call (attempt_kind panel/judge) sharing request_id, before the
guardrail check so a blocked response still bills the full fan-out;
suppresses the entry-level usage event to avoid double-emit.
- Client sees the ensemble's own model name; panel/judge model names never
reach the client (synthesized answer, response.model, or error messages).
- Tests: 8 executor unit tests + 5 handler e2e (fan-out->judge, tools/stream
400, insufficient-panel->502, output-block-still-bills-panel+judge).
Streaming is rejected for now (no single-response->SSE path); it lands in a
follow-up. Dashboard needs to recognize the new attempt_kind values
"panel"/"judge" (free-string on the wire; tracked CP-side).
Refs #601, api7/AISIX-Cloud#804.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5a349dc8-7f5e-4ad0-bc1f-8f3c51557012

📥 Commits

Reviewing files that changed from the base of the PR and between 8375c85 and 917d692.

📒 Files selected for processing (7)
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/ensemble.rs
  • crates/aisix-proxy/src/lib.rs
  • schemas/resources/ensemble.schema.json
  • schemas/resources/model.schema.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • schemas/resources/ensemble.schema.json
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/ensemble.rs

📝 Walkthrough

Walkthrough

Adds "ensemble" model support across the proxy stack: new PanelMember, Judge, and EnsembleConfig Rust types in aisix-core, an optional ensemble field on Model, updated Admin API JSON schema validation with mutual exclusivity rules, a new run_ensemble orchestration engine in aisix-proxy that fans out to panel members concurrently and uses a judge model to synthesize responses, and a dispatch_ensemble path in the chat dispatch loop.

Changes

Ensemble Model Feature

Layer / File(s)Summary
Ensemble data model types and module wiring
crates/aisix-core/src/models/ensemble.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/models/model.rs
Defines PanelMember, Judge, and EnsembleConfig structs with deny_unknown_fields, helper methods (min_responses_or_default(), timeout()), and convenience constructors. Adds optional ensemble: Option<EnsembleConfig> field and is_ensemble() method to Model. Re-exports all three types from the models module. Unit tests cover deserialization, clamping to panel size, sentinel handling for timeout_ms=0, rejection of unknown fields, and constructor behavior.
JSON schema definitions and Admin API validation
crates/aisix-core/src/models/schema.rs, schemas/resources/ensemble.schema.json, schemas/resources/model.schema.json, crates/aisix-core/src/bin/dump-schema.rs
Adds ensemble object schema inside model_schema() with required panel/judge structure and field constraints. Updates oneOf XOR rules so ensemble is mutually exclusive with routing, direct upstream fields, background_model_check, and cooldown. Introduces standalone ensemble.schema.json and extends model.schema.json with EnsembleConfig/Judge/PanelMember definitions under additionalProperties: false. Registers EnsembleConfig in the dump-schema binary. Schema tests cover valid payloads (including with top-level allowed_cidrs and rate_limit) and failure cases for mutual exclusivity and malformed panel structure.
Ensemble orchestration engine
crates/aisix-proxy/src/ensemble.rs
Introduces ModelCaller trait and ProxyModelCaller (resolves models via AisixSnapshot, builds BridgeContext with optional deadline, dispatches via bridge.chat). Implements run_ensemble that concurrently fans out to panel members applying per-member temperature/seed overrides and forcing non-streaming, enforces min_responses_or_default(), builds a labeled judge prompt (with per-candidate truncation at UTF-8 boundaries), and calls the judge via call_judge_with_retry with transient-error classification. Defines PanelOutcome, EnsembleOutcome, and EnsembleError with http_status() (panel exhaustion → 502; judge failures delegated). Unit tests cover fan-out, insufficient panel, partial success when minimum is met, judge retry semantics (once on transient, not on non-transient), per-member temperature overrides vs inherited request temperature, and prompt privacy (no panel model-name leakage).
Chat dispatch integration
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/dispatch.rs, crates/aisix-proxy/src/lib.rs
Skips single-target provider/bridge preflight validation for ensemble models via a new guard in require_provider. In dispatch, adds an early is_ensemble() branch that routes to a new dispatch_ensemble function before streaming/failover routing. dispatch_ensemble rejects ensemble requests containing tools or forcing tool_choice with 400, rejects stream: true requests, runs run_ensemble, and on error commits survivor panel tokens while emitting per-panel UsageEvents before returning mapped DispatchFailure. On success, aggregates panel+judge tokens once, emits per-panel (attempt_kind: "panel") and per-judge (attempt_kind: "judge") UsageEvents with re-resolved sub-model identifiers, evaluates output guardrails on the synthesized response (emits guardrail_blocked on Block), and returns Success with telemetry_handled_by_stream suppression. Declares ensemble module in lib.rs.
End-to-end integration tests
crates/aisix-proxy/src/lib.rs
Adds test helpers (direct_model_entry, ensemble_model_entry, ensemble_model_entry_min, mount_panel_and_judge) and comprehensive integration tests: successful fan-out + judge synthesis with client receiving synthesized answer under ensemble model name and judge usage; rejection of ensemble requests containing tools (400); rejection of stream: true (400); output guardrail blocking judge with correct per-sub-call usage telemetry (panel members and judge, guardrail_blocked set); min_responses shortfall mapping to 502 with usage telemetry only for surviving calls; empty tools: [] and tool_choice: "none" allowance; forced tool_choice objects rejected (400); misconfigured judge error messages do not leak identifiers; non-chat endpoints (e.g., /v1/embeddings) reject ensemble with explicit error message; judge upstream 5xx collapses to 502 while billing panel members and emitting no judge usage when judge fails.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant dispatch as chat.rs<br/>dispatch
participant dispatch_ensemble as dispatch_ensemble
participant run_ensemble as run_ensemble
participant ProxyModelCaller as ProxyModelCaller
rect rgba(70, 130, 180, 0.5)
Note over dispatch,ProxyModelCaller: Panel fan-out
Client->>dispatch: POST /v1/chat/completions<br/>(ensemble model)
dispatch->>dispatch: is_ensemble() check
dispatch->>dispatch_ensemble: validate tools,<br/>stream, ensemble config
dispatch_ensemble->>run_ensemble: run_ensemble(req, config, caller)
run_ensemble->>ProxyModelCaller: call(panel_member, panel_request) ×N
ProxyModelCaller-->>run_ensemble: collect successes
run_ensemble->>run_ensemble: enforce<br/>min_responses_or_default()
end
rect rgba(60, 179, 113, 0.5)
Note over run_ensemble,ProxyModelCaller: Judge synthesis with retry
run_ensemble->>run_ensemble: build judge_request<br/>with labeled candidates
run_ensemble->>ProxyModelCaller: call_judge_with_retry(judge)
ProxyModelCaller-->>run_ensemble: EnsembleOutcome
end
rect rgba(220, 100, 60, 0.5)
Note over dispatch_ensemble,Client: Guardrails + telemetry
dispatch_ensemble->>dispatch_ensemble: emit UsageEvents<br/>per panel member & judge
dispatch_ensemble->>dispatch_ensemble: evaluate output guardrails
dispatch_ensemble-->>Client: synthesized ChatResponse<br/>(or 400/502/ContentFiltered)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

An independent audit of #604 found a client-facing leak, a CI-blocking
schema drift, and billing gaps on the ensemble error exits.
- Redact ensemble member identity from client errors: a misconfigured
judge's display_name + provider_key_id no longer reach error.message
(the resolve_provider_key path still forwarded it). Detail kept in logs.
- Regenerate ensemble/model schema JSON so the schema-drift CI gate
passes (the timeout_ms doc edit hadn't been regenerated).
- Bill the panel on every error exit, not just success. Panel members
that already round-tripped upstream now emit usage + commit tokens on
the insufficient-panel (502) and judge-failure paths, matching the
output-guardrail-block path. EnsembleError carries the surviving
PanelOutcomes.
- tools: [] (empty) no longer 400s; only a non-empty tools array or a
forcing tool_choice rejects (many SDKs always send tools: []).
- Non-chat endpoints reject an ensemble model with an explicit, accurate
400 (was incidental and said "routing"); added coverage.
- Remove dead EnsembleConfig::is_empty().
Tests: +6 e2e (judge-error redaction, 502-path + judge-failure panel
billing, tools:[] accepted, non-chat 400). 259 core + 439 proxy green;
clippy -D warnings clean; schema-drift gate clean.
Refs #601.
@moonming

Copy link
Copy Markdown
MemberAuthor

Post-PR independent audit — all findings resolved (917d692)

Per our merge-gate, an independent cold-review agent audited this PR. Findings and resolutions:

HIGH

  • Client-facing leak — a misconfigured judge's display_name + provider_key_id reached the client error.message (the earlier redaction only covered the get_by_name path; resolve_provider_key still forwarded it). Fixed — redacted; detail kept in server logs. Test: ensemble_misconfigured_judge_does_not_leak_internal_config.
  • Schema-drift CI would be red — the timeout_ms doc edit wasn't regenerated into the schema JSON. Fixed — regenerated; dump-schema + git diff --exit-code schemas/ clean.

MEDIUM

  • Billing under-report on error exits — panel members that already round-tripped upstream weren't billed on the insufficient-panel (502) path. Fixed — emit usage + commit tokens for the survivors on the 502 path, matching the output-guardrail-block path. Also extended to the judge-failure path (same class, surfaced during the fix). Tests: ensemble_insufficient_panel_returns_502, ensemble_judge_failure_still_bills_panel.
  • tools: [] wrongly 400'd — empty arrays (which many SDKs always send) were rejected. Fixed — only a non-empty tools or a forcing tool_choice rejects. Tests: ensemble_allows_empty_tools_array, ensemble_allows_tool_choice_none.
  • Non-chat rejection incidental + misleading — non-chat endpoints rejected ensemble models with a "routing" message and no test. Fixed — explicit, accurate 400. Test: ensemble_model_on_embeddings_returns_400_with_explicit_message.

Verified clean by the audit: the 3-way schema XOR (direct | routing | ensemble), single token-commit, double-emit suppression, response-body redaction, and the cross-repo wire contract vs api7/AISIX-Cloud#804.

Tracked follow-ups (not blocking this PR):

  • Total judge-prompt budget cap (per-candidate truncation exists; a panel-wide cap folds into the hardening PR under [DP] ensemble model — parallel panel fan-out + judge synthesis #601).
  • [CP] the dashboard should recognize the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion) — tracked under api7/AISIX-Cloud#804.

259 core + 439 proxy tests green; clippy --all-targets -- -D warnings clean; schema-drift gate clean.

@moonming
moonming merged commit ca2542e into mainJun 15, 2026
10 checks passed
@moonming
moonming deleted the feat/ensemble-model branch June 15, 2026 03:15
moonming added a commit that referenced this pull request Jun 15, 2026
Streams the judge's synthesized answer for ensemble models (stream:true). Panel buffers non-streaming, then the judge's tokens stream via the existing build_sse_stream (reused unmodified). Independently audited: safe to merge. Follows #604; tracked in #601.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DP] ensemble model — parallel panel fan-out + judge synthesis

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming) - #604

Merged
moonming merged 3 commits into
mainfrom
feat/ensemble-model
Jun 15, 2026
Merged

feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming)#604
moonming merged 3 commits into
mainfrom
feat/ensemble-model

Conversation

@moonming

@moonmingmoonming commented Jun 15, 2026

Copy link
Copy Markdown
Member

Summary

First slice of the ensemble virtual-model feature (DP side, tracked in #601; CP umbrella api7/AISIX-Cloud#804). An ensemble model fans one /v1/chat/completions request out to a panel of N upstream models in parallel, then a judge model synthesizes a single answer. It extends the existing virtual-model machinery: routing picks one target; ensemble calls all and synthesizes.

This PR lands the config layer and the non-streaming dispatch. Streaming, per-target rate-limiting, and hardening are separate follow-ups (see Deferred) — so this does notclose#601.

What's in this PR

1a89a6e — config layer (new ensemble model kind)

  • EnsembleConfig { panel: [{model, temperature?, seed?, weight?}], judge: {model, synthesis_prompt?}, min_responses?, timeout_ms? } in aisix-core.
  • Model.ensemble + Model::is_ensemble() (DP convention: virtual-model kind = presence of the block, same as routing).
  • Both DP schemas updated in lockstep so an ensemble row is never silently dropped on the kine watch path: the schemars-generated schemas/resources/model.schema.json (+ a standalone ensemble.schema.json) and the hand-written runtime validator models/schema.rs::model_schema(), whose oneOf is now a 3-way XOR (direct | routing | ensemble).

8375c85 — non-streaming fan-out dispatch

  • ensemble.rs: pure executor run_ensemble (parallel join_all fan-out, min_responses gate, per-member temperature/seed override for self-ensemble diversity, judge synthesis with neutral Answer 1..N labels + per-candidate truncation, judge retry-once-on-transient), plus the production ProxyModelCaller that resolves each member → ProviderKey → Bridge via the existing routing helpers.
  • chat.rs::dispatch_ensemble: branches at the post-rate-limit-reservation seam; rejects tools/tool_choice and stream:true with 400; runs the output guardrail on the synthesized answer; commits the aggregate panel+judge tokens once against the single entry-level reservation; emits one usage event per sub-call (attempt_kind = panel/judge, sharing request_id) before the guardrail check so a blocked response still bills the full fan-out; suppresses the entry-level usage event (no double-emit).
  • The client sees the ensemble's own model name; panel/judge model names never reach the client (synthesized answer, response.model, or error messages).

Behavior / contract

  • Config-driven: the operator curates the panel + judge; clients just call the model name. No per-request panel override.
  • Self-ensemble works with a single provider key (panel = the same model with per-member temperature).
  • v1 scope: chat-only; no server-side web retrieval; tools/streaming rejected (see Deferred).

Deferred (separate PRs, tracked in #601)

  • Streamingstream:true is 400-rejected here; there is no single-ChatResponse→SSE path today, so it's net-new.
  • Per-target rate-limiting — today only the entry model is debited; per-panel-member reservation is net-new (avoids N× provider-quota amplification).
  • Hardening — the response cache key must include the ensemble config; misc edges.
  • [CP] api7/AISIX-Cloud#804 — schema/projection/dashboard so operators can create ensemble models; the dashboard must also learn the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion).

Independent audit (merge-gate)

An independent cold-review agent audited this change. It confirmed: no double-emit / double-commit; authorization consistent with routing (panel/judge are internal targets — no escalation beyond what routing already allows); sound snapshot lifetimes across the fan-out; model names not leaked to the client response body. It caught and we fixed:

  • HIGH — on an output-guardrail block, the per-sub-call usage events were skipped while the panel tokens were already committed → cp-api under-reported a blocked-but-billed request. Fixed (emit before the guardrail check on both paths; charge: None on block) and locked with ensemble_output_block_still_emits_panel_and_judge_usage.
  • LOW — a misconfigured judge's model name leaked into the client-visible error.message → redacted (detail kept in server logs).
  • LOWtimeout_ms doc/behavior mismatch → behavior made uniform (now also applies to the judge call) + doc corrected.

Test plan

  • cargo test -p aisix-core259 pass (config types, runtime-validator 3-way XOR, schema round-trip; e.g. model_ensemble_with_direct_fields_fails, model_ensemble_with_routing_fails).
  • cargo test -p aisix-proxy433 pass: 8 executor unit tests + 5 handler e2e through a real OpenAiBridge against wiremock:
    • ensemble_fans_out_to_panel_and_returns_judge_synthesis (judge answer returned, model == "council", no upstream-id leak)
    • ensemble_rejects_tool_requests_with_400, ensemble_rejects_streaming_with_400
    • ensemble_insufficient_panel_returns_502
    • ensemble_output_block_still_emits_panel_and_judge_usage (HIGH regression lock — asserts 2 panel + 1 judge usage events still fire on a block)
  • cargo clippy --all-targets -- -D warnings — clean; full workspace builds.
  • CP-side e2e (create an ensemble model via the dashboard → call it end-to-end) lands with api7/AISIX-Cloud#804.

Summary by CodeRabbit

New Features

  • Added ensemble model support for chat completions, running multiple panel models in parallel and using a designated judge model to synthesize the final response.
  • Supports per-panel sampling overrides plus configurable minimum successful responses and optional per-member timeouts.
  • Enforces ensemble-only behavior in chat completions, with validation for streaming/tool fields and improved error handling.

Bug Fixes

  • Ensured schema generation and validation include the new ensemble configuration, with strict JSON field rejection and correct canonical schema outputs.

Introduce a third virtual-model kind alongside direct and routing: an
`ensemble` model fans a chat request out to a panel of models and
synthesizes their responses via a judge model. This lands the config
layer only; DP fan-out/synthesis dispatch is a follow-up.
- New EnsembleConfig/PanelMember/Judge types (models/ensemble.rs),
mirroring the routing config shape (deny_unknown_fields, _or_default
accessors, per-member temperature/seed for self-ensemble diversity).
- Model.ensemble field + Model::is_ensemble().
- Runtime validator (models/schema.rs): add the ensemble block and
widen the direct-vs-routing oneOf to a three-way XOR
(direct | routing | ensemble), so an ensemble row is neither silently
dropped on the kine watch path nor allowed to mix shapes.
- Regenerate schemas; emit a standalone ensemble.schema.json for CP
consumption (parity with routing.schema.json).
Refs #601, api7/AISIX-Cloud#804.
Wire the ensemble virtual-model kind end-to-end (non-streaming). One
/v1/chat/completions request to an `ensemble` model fans out to its panel
of models in parallel, then a judge model synthesizes a single answer.
- ensemble.rs: pure executor `run_ensemble` (parallel fan-out, min_responses
gate, per-member temperature/seed for self-ensemble diversity, judge
synthesis with neutral candidate labels + per-candidate truncation, judge
retry-once-on-transient), plus the production `ProxyModelCaller` that
resolves each member display_name -> ProviderKey -> Bridge.
- chat.rs: `dispatch_ensemble` branch (after the entry-level rate-limit
reservation, before the failover/streaming machinery): rejects tools +
streaming with 400; runs the output guardrail on the synthesized answer;
commits the aggregate panel+judge tokens once; emits one usage event per
sub-call (attempt_kind panel/judge) sharing request_id, before the
guardrail check so a blocked response still bills the full fan-out;
suppresses the entry-level usage event to avoid double-emit.
- Client sees the ensemble's own model name; panel/judge model names never
reach the client (synthesized answer, response.model, or error messages).
- Tests: 8 executor unit tests + 5 handler e2e (fan-out->judge, tools/stream
400, insufficient-panel->502, output-block-still-bills-panel+judge).
Streaming is rejected for now (no single-response->SSE path); it lands in a
follow-up. Dashboard needs to recognize the new attempt_kind values
"panel"/"judge" (free-string on the wire; tracked CP-side).
Refs #601, api7/AISIX-Cloud#804.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5a349dc8-7f5e-4ad0-bc1f-8f3c51557012

📥 Commits

Reviewing files that changed from the base of the PR and between 8375c85 and 917d692.

📒 Files selected for processing (7)
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/ensemble.rs
  • crates/aisix-proxy/src/lib.rs
  • schemas/resources/ensemble.schema.json
  • schemas/resources/model.schema.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • schemas/resources/ensemble.schema.json
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/ensemble.rs

📝 Walkthrough

Walkthrough

Adds "ensemble" model support across the proxy stack: new PanelMember, Judge, and EnsembleConfig Rust types in aisix-core, an optional ensemble field on Model, updated Admin API JSON schema validation with mutual exclusivity rules, a new run_ensemble orchestration engine in aisix-proxy that fans out to panel members concurrently and uses a judge model to synthesize responses, and a dispatch_ensemble path in the chat dispatch loop.

Changes

Ensemble Model Feature

Layer / File(s)Summary
Ensemble data model types and module wiring
crates/aisix-core/src/models/ensemble.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/models/model.rs
Defines PanelMember, Judge, and EnsembleConfig structs with deny_unknown_fields, helper methods (min_responses_or_default(), timeout()), and convenience constructors. Adds optional ensemble: Option<EnsembleConfig> field and is_ensemble() method to Model. Re-exports all three types from the models module. Unit tests cover deserialization, clamping to panel size, sentinel handling for timeout_ms=0, rejection of unknown fields, and constructor behavior.
JSON schema definitions and Admin API validation
crates/aisix-core/src/models/schema.rs, schemas/resources/ensemble.schema.json, schemas/resources/model.schema.json, crates/aisix-core/src/bin/dump-schema.rs
Adds ensemble object schema inside model_schema() with required panel/judge structure and field constraints. Updates oneOf XOR rules so ensemble is mutually exclusive with routing, direct upstream fields, background_model_check, and cooldown. Introduces standalone ensemble.schema.json and extends model.schema.json with EnsembleConfig/Judge/PanelMember definitions under additionalProperties: false. Registers EnsembleConfig in the dump-schema binary. Schema tests cover valid payloads (including with top-level allowed_cidrs and rate_limit) and failure cases for mutual exclusivity and malformed panel structure.
Ensemble orchestration engine
crates/aisix-proxy/src/ensemble.rs
Introduces ModelCaller trait and ProxyModelCaller (resolves models via AisixSnapshot, builds BridgeContext with optional deadline, dispatches via bridge.chat). Implements run_ensemble that concurrently fans out to panel members applying per-member temperature/seed overrides and forcing non-streaming, enforces min_responses_or_default(), builds a labeled judge prompt (with per-candidate truncation at UTF-8 boundaries), and calls the judge via call_judge_with_retry with transient-error classification. Defines PanelOutcome, EnsembleOutcome, and EnsembleError with http_status() (panel exhaustion → 502; judge failures delegated). Unit tests cover fan-out, insufficient panel, partial success when minimum is met, judge retry semantics (once on transient, not on non-transient), per-member temperature overrides vs inherited request temperature, and prompt privacy (no panel model-name leakage).
Chat dispatch integration
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/dispatch.rs, crates/aisix-proxy/src/lib.rs
Skips single-target provider/bridge preflight validation for ensemble models via a new guard in require_provider. In dispatch, adds an early is_ensemble() branch that routes to a new dispatch_ensemble function before streaming/failover routing. dispatch_ensemble rejects ensemble requests containing tools or forcing tool_choice with 400, rejects stream: true requests, runs run_ensemble, and on error commits survivor panel tokens while emitting per-panel UsageEvents before returning mapped DispatchFailure. On success, aggregates panel+judge tokens once, emits per-panel (attempt_kind: "panel") and per-judge (attempt_kind: "judge") UsageEvents with re-resolved sub-model identifiers, evaluates output guardrails on the synthesized response (emits guardrail_blocked on Block), and returns Success with telemetry_handled_by_stream suppression. Declares ensemble module in lib.rs.
End-to-end integration tests
crates/aisix-proxy/src/lib.rs
Adds test helpers (direct_model_entry, ensemble_model_entry, ensemble_model_entry_min, mount_panel_and_judge) and comprehensive integration tests: successful fan-out + judge synthesis with client receiving synthesized answer under ensemble model name and judge usage; rejection of ensemble requests containing tools (400); rejection of stream: true (400); output guardrail blocking judge with correct per-sub-call usage telemetry (panel members and judge, guardrail_blocked set); min_responses shortfall mapping to 502 with usage telemetry only for surviving calls; empty tools: [] and tool_choice: "none" allowance; forced tool_choice objects rejected (400); misconfigured judge error messages do not leak identifiers; non-chat endpoints (e.g., /v1/embeddings) reject ensemble with explicit error message; judge upstream 5xx collapses to 502 while billing panel members and emitting no judge usage when judge fails.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant dispatch as chat.rs<br/>dispatch
participant dispatch_ensemble as dispatch_ensemble
participant run_ensemble as run_ensemble
participant ProxyModelCaller as ProxyModelCaller
rect rgba(70, 130, 180, 0.5)
Note over dispatch,ProxyModelCaller: Panel fan-out
Client->>dispatch: POST /v1/chat/completions<br/>(ensemble model)
dispatch->>dispatch: is_ensemble() check
dispatch->>dispatch_ensemble: validate tools,<br/>stream, ensemble config
dispatch_ensemble->>run_ensemble: run_ensemble(req, config, caller)
run_ensemble->>ProxyModelCaller: call(panel_member, panel_request) ×N
ProxyModelCaller-->>run_ensemble: collect successes
run_ensemble->>run_ensemble: enforce<br/>min_responses_or_default()
end
rect rgba(60, 179, 113, 0.5)
Note over run_ensemble,ProxyModelCaller: Judge synthesis with retry
run_ensemble->>run_ensemble: build judge_request<br/>with labeled candidates
run_ensemble->>ProxyModelCaller: call_judge_with_retry(judge)
ProxyModelCaller-->>run_ensemble: EnsembleOutcome
end
rect rgba(220, 100, 60, 0.5)
Note over dispatch_ensemble,Client: Guardrails + telemetry
dispatch_ensemble->>dispatch_ensemble: emit UsageEvents<br/>per panel member & judge
dispatch_ensemble->>dispatch_ensemble: evaluate output guardrails
dispatch_ensemble-->>Client: synthesized ChatResponse<br/>(or 400/502/ContentFiltered)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

An independent audit of #604 found a client-facing leak, a CI-blocking
schema drift, and billing gaps on the ensemble error exits.
- Redact ensemble member identity from client errors: a misconfigured
judge's display_name + provider_key_id no longer reach error.message
(the resolve_provider_key path still forwarded it). Detail kept in logs.
- Regenerate ensemble/model schema JSON so the schema-drift CI gate
passes (the timeout_ms doc edit hadn't been regenerated).
- Bill the panel on every error exit, not just success. Panel members
that already round-tripped upstream now emit usage + commit tokens on
the insufficient-panel (502) and judge-failure paths, matching the
output-guardrail-block path. EnsembleError carries the surviving
PanelOutcomes.
- tools: [] (empty) no longer 400s; only a non-empty tools array or a
forcing tool_choice rejects (many SDKs always send tools: []).
- Non-chat endpoints reject an ensemble model with an explicit, accurate
400 (was incidental and said "routing"); added coverage.
- Remove dead EnsembleConfig::is_empty().
Tests: +6 e2e (judge-error redaction, 502-path + judge-failure panel
billing, tools:[] accepted, non-chat 400). 259 core + 439 proxy green;
clippy -D warnings clean; schema-drift gate clean.
Refs #601.
@moonming

Copy link
Copy Markdown
MemberAuthor

Post-PR independent audit — all findings resolved (917d692)

Per our merge-gate, an independent cold-review agent audited this PR. Findings and resolutions:

HIGH

  • Client-facing leak — a misconfigured judge's display_name + provider_key_id reached the client error.message (the earlier redaction only covered the get_by_name path; resolve_provider_key still forwarded it). Fixed — redacted; detail kept in server logs. Test: ensemble_misconfigured_judge_does_not_leak_internal_config.
  • Schema-drift CI would be red — the timeout_ms doc edit wasn't regenerated into the schema JSON. Fixed — regenerated; dump-schema + git diff --exit-code schemas/ clean.

MEDIUM

  • Billing under-report on error exits — panel members that already round-tripped upstream weren't billed on the insufficient-panel (502) path. Fixed — emit usage + commit tokens for the survivors on the 502 path, matching the output-guardrail-block path. Also extended to the judge-failure path (same class, surfaced during the fix). Tests: ensemble_insufficient_panel_returns_502, ensemble_judge_failure_still_bills_panel.
  • tools: [] wrongly 400'd — empty arrays (which many SDKs always send) were rejected. Fixed — only a non-empty tools or a forcing tool_choice rejects. Tests: ensemble_allows_empty_tools_array, ensemble_allows_tool_choice_none.
  • Non-chat rejection incidental + misleading — non-chat endpoints rejected ensemble models with a "routing" message and no test. Fixed — explicit, accurate 400. Test: ensemble_model_on_embeddings_returns_400_with_explicit_message.

Verified clean by the audit: the 3-way schema XOR (direct | routing | ensemble), single token-commit, double-emit suppression, response-body redaction, and the cross-repo wire contract vs api7/AISIX-Cloud#804.

Tracked follow-ups (not blocking this PR):

  • Total judge-prompt budget cap (per-candidate truncation exists; a panel-wide cap folds into the hardening PR under [DP] ensemble model — parallel panel fan-out + judge synthesis #601).
  • [CP] the dashboard should recognize the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion) — tracked under api7/AISIX-Cloud#804.

259 core + 439 proxy tests green; clippy --all-targets -- -D warnings clean; schema-drift gate clean.

@moonming
moonming merged commit ca2542e into mainJun 15, 2026
10 checks passed
@moonming
moonming deleted the feat/ensemble-model branch June 15, 2026 03:15
moonming added a commit that referenced this pull request Jun 15, 2026
Streams the judge's synthesized answer for ensemble models (stream:true). Panel buffers non-streaming, then the judge's tokens stream via the existing build_sse_stream (reused unmodified). Independently audited: safe to merge. Follows #604; tracked in #601.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DP] ensemble model — parallel panel fan-out + judge synthesis

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming) - #604

Merged
moonming merged 3 commits into
mainfrom
feat/ensemble-model
Jun 15, 2026
Merged

feat: ensemble model — panel + judge fan-out dispatch (config + non-streaming)#604
moonming merged 3 commits into
mainfrom
feat/ensemble-model

Conversation

@moonming

@moonmingmoonming commented Jun 15, 2026

Copy link
Copy Markdown
Member

Summary

First slice of the ensemble virtual-model feature (DP side, tracked in #601; CP umbrella api7/AISIX-Cloud#804). An ensemble model fans one /v1/chat/completions request out to a panel of N upstream models in parallel, then a judge model synthesizes a single answer. It extends the existing virtual-model machinery: routing picks one target; ensemble calls all and synthesizes.

This PR lands the config layer and the non-streaming dispatch. Streaming, per-target rate-limiting, and hardening are separate follow-ups (see Deferred) — so this does notclose#601.

What's in this PR

1a89a6e — config layer (new ensemble model kind)

  • EnsembleConfig { panel: [{model, temperature?, seed?, weight?}], judge: {model, synthesis_prompt?}, min_responses?, timeout_ms? } in aisix-core.
  • Model.ensemble + Model::is_ensemble() (DP convention: virtual-model kind = presence of the block, same as routing).
  • Both DP schemas updated in lockstep so an ensemble row is never silently dropped on the kine watch path: the schemars-generated schemas/resources/model.schema.json (+ a standalone ensemble.schema.json) and the hand-written runtime validator models/schema.rs::model_schema(), whose oneOf is now a 3-way XOR (direct | routing | ensemble).

8375c85 — non-streaming fan-out dispatch

  • ensemble.rs: pure executor run_ensemble (parallel join_all fan-out, min_responses gate, per-member temperature/seed override for self-ensemble diversity, judge synthesis with neutral Answer 1..N labels + per-candidate truncation, judge retry-once-on-transient), plus the production ProxyModelCaller that resolves each member → ProviderKey → Bridge via the existing routing helpers.
  • chat.rs::dispatch_ensemble: branches at the post-rate-limit-reservation seam; rejects tools/tool_choice and stream:true with 400; runs the output guardrail on the synthesized answer; commits the aggregate panel+judge tokens once against the single entry-level reservation; emits one usage event per sub-call (attempt_kind = panel/judge, sharing request_id) before the guardrail check so a blocked response still bills the full fan-out; suppresses the entry-level usage event (no double-emit).
  • The client sees the ensemble's own model name; panel/judge model names never reach the client (synthesized answer, response.model, or error messages).

Behavior / contract

  • Config-driven: the operator curates the panel + judge; clients just call the model name. No per-request panel override.
  • Self-ensemble works with a single provider key (panel = the same model with per-member temperature).
  • v1 scope: chat-only; no server-side web retrieval; tools/streaming rejected (see Deferred).

Deferred (separate PRs, tracked in #601)

  • Streamingstream:true is 400-rejected here; there is no single-ChatResponse→SSE path today, so it's net-new.
  • Per-target rate-limiting — today only the entry model is debited; per-panel-member reservation is net-new (avoids N× provider-quota amplification).
  • Hardening — the response cache key must include the ensemble config; misc edges.
  • [CP] api7/AISIX-Cloud#804 — schema/projection/dashboard so operators can create ensemble models; the dashboard must also learn the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion).

Independent audit (merge-gate)

An independent cold-review agent audited this change. It confirmed: no double-emit / double-commit; authorization consistent with routing (panel/judge are internal targets — no escalation beyond what routing already allows); sound snapshot lifetimes across the fan-out; model names not leaked to the client response body. It caught and we fixed:

  • HIGH — on an output-guardrail block, the per-sub-call usage events were skipped while the panel tokens were already committed → cp-api under-reported a blocked-but-billed request. Fixed (emit before the guardrail check on both paths; charge: None on block) and locked with ensemble_output_block_still_emits_panel_and_judge_usage.
  • LOW — a misconfigured judge's model name leaked into the client-visible error.message → redacted (detail kept in server logs).
  • LOWtimeout_ms doc/behavior mismatch → behavior made uniform (now also applies to the judge call) + doc corrected.

Test plan

  • cargo test -p aisix-core259 pass (config types, runtime-validator 3-way XOR, schema round-trip; e.g. model_ensemble_with_direct_fields_fails, model_ensemble_with_routing_fails).
  • cargo test -p aisix-proxy433 pass: 8 executor unit tests + 5 handler e2e through a real OpenAiBridge against wiremock:
    • ensemble_fans_out_to_panel_and_returns_judge_synthesis (judge answer returned, model == "council", no upstream-id leak)
    • ensemble_rejects_tool_requests_with_400, ensemble_rejects_streaming_with_400
    • ensemble_insufficient_panel_returns_502
    • ensemble_output_block_still_emits_panel_and_judge_usage (HIGH regression lock — asserts 2 panel + 1 judge usage events still fire on a block)
  • cargo clippy --all-targets -- -D warnings — clean; full workspace builds.
  • CP-side e2e (create an ensemble model via the dashboard → call it end-to-end) lands with api7/AISIX-Cloud#804.

Summary by CodeRabbit

New Features

  • Added ensemble model support for chat completions, running multiple panel models in parallel and using a designated judge model to synthesize the final response.
  • Supports per-panel sampling overrides plus configurable minimum successful responses and optional per-member timeouts.
  • Enforces ensemble-only behavior in chat completions, with validation for streaming/tool fields and improved error handling.

Bug Fixes

  • Ensured schema generation and validation include the new ensemble configuration, with strict JSON field rejection and correct canonical schema outputs.

Introduce a third virtual-model kind alongside direct and routing: an
`ensemble` model fans a chat request out to a panel of models and
synthesizes their responses via a judge model. This lands the config
layer only; DP fan-out/synthesis dispatch is a follow-up.
- New EnsembleConfig/PanelMember/Judge types (models/ensemble.rs),
mirroring the routing config shape (deny_unknown_fields, _or_default
accessors, per-member temperature/seed for self-ensemble diversity).
- Model.ensemble field + Model::is_ensemble().
- Runtime validator (models/schema.rs): add the ensemble block and
widen the direct-vs-routing oneOf to a three-way XOR
(direct | routing | ensemble), so an ensemble row is neither silently
dropped on the kine watch path nor allowed to mix shapes.
- Regenerate schemas; emit a standalone ensemble.schema.json for CP
consumption (parity with routing.schema.json).
Refs #601, api7/AISIX-Cloud#804.
Wire the ensemble virtual-model kind end-to-end (non-streaming). One
/v1/chat/completions request to an `ensemble` model fans out to its panel
of models in parallel, then a judge model synthesizes a single answer.
- ensemble.rs: pure executor `run_ensemble` (parallel fan-out, min_responses
gate, per-member temperature/seed for self-ensemble diversity, judge
synthesis with neutral candidate labels + per-candidate truncation, judge
retry-once-on-transient), plus the production `ProxyModelCaller` that
resolves each member display_name -> ProviderKey -> Bridge.
- chat.rs: `dispatch_ensemble` branch (after the entry-level rate-limit
reservation, before the failover/streaming machinery): rejects tools +
streaming with 400; runs the output guardrail on the synthesized answer;
commits the aggregate panel+judge tokens once; emits one usage event per
sub-call (attempt_kind panel/judge) sharing request_id, before the
guardrail check so a blocked response still bills the full fan-out;
suppresses the entry-level usage event to avoid double-emit.
- Client sees the ensemble's own model name; panel/judge model names never
reach the client (synthesized answer, response.model, or error messages).
- Tests: 8 executor unit tests + 5 handler e2e (fan-out->judge, tools/stream
400, insufficient-panel->502, output-block-still-bills-panel+judge).
Streaming is rejected for now (no single-response->SSE path); it lands in a
follow-up. Dashboard needs to recognize the new attempt_kind values
"panel"/"judge" (free-string on the wire; tracked CP-side).
Refs #601, api7/AISIX-Cloud#804.
@coderabbitai

coderabbitaiBot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5a349dc8-7f5e-4ad0-bc1f-8f3c51557012

📥 Commits

Reviewing files that changed from the base of the PR and between 8375c85 and 917d692.

📒 Files selected for processing (7)
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/chat.rs
  • crates/aisix-proxy/src/dispatch.rs
  • crates/aisix-proxy/src/ensemble.rs
  • crates/aisix-proxy/src/lib.rs
  • schemas/resources/ensemble.schema.json
  • schemas/resources/model.schema.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • schemas/resources/ensemble.schema.json
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-proxy/src/ensemble.rs

📝 Walkthrough

Walkthrough

Adds "ensemble" model support across the proxy stack: new PanelMember, Judge, and EnsembleConfig Rust types in aisix-core, an optional ensemble field on Model, updated Admin API JSON schema validation with mutual exclusivity rules, a new run_ensemble orchestration engine in aisix-proxy that fans out to panel members concurrently and uses a judge model to synthesize responses, and a dispatch_ensemble path in the chat dispatch loop.

Changes

Ensemble Model Feature

Layer / File(s)Summary
Ensemble data model types and module wiring
crates/aisix-core/src/models/ensemble.rs, crates/aisix-core/src/models/mod.rs, crates/aisix-core/src/models/model.rs
Defines PanelMember, Judge, and EnsembleConfig structs with deny_unknown_fields, helper methods (min_responses_or_default(), timeout()), and convenience constructors. Adds optional ensemble: Option<EnsembleConfig> field and is_ensemble() method to Model. Re-exports all three types from the models module. Unit tests cover deserialization, clamping to panel size, sentinel handling for timeout_ms=0, rejection of unknown fields, and constructor behavior.
JSON schema definitions and Admin API validation
crates/aisix-core/src/models/schema.rs, schemas/resources/ensemble.schema.json, schemas/resources/model.schema.json, crates/aisix-core/src/bin/dump-schema.rs
Adds ensemble object schema inside model_schema() with required panel/judge structure and field constraints. Updates oneOf XOR rules so ensemble is mutually exclusive with routing, direct upstream fields, background_model_check, and cooldown. Introduces standalone ensemble.schema.json and extends model.schema.json with EnsembleConfig/Judge/PanelMember definitions under additionalProperties: false. Registers EnsembleConfig in the dump-schema binary. Schema tests cover valid payloads (including with top-level allowed_cidrs and rate_limit) and failure cases for mutual exclusivity and malformed panel structure.
Ensemble orchestration engine
crates/aisix-proxy/src/ensemble.rs
Introduces ModelCaller trait and ProxyModelCaller (resolves models via AisixSnapshot, builds BridgeContext with optional deadline, dispatches via bridge.chat). Implements run_ensemble that concurrently fans out to panel members applying per-member temperature/seed overrides and forcing non-streaming, enforces min_responses_or_default(), builds a labeled judge prompt (with per-candidate truncation at UTF-8 boundaries), and calls the judge via call_judge_with_retry with transient-error classification. Defines PanelOutcome, EnsembleOutcome, and EnsembleError with http_status() (panel exhaustion → 502; judge failures delegated). Unit tests cover fan-out, insufficient panel, partial success when minimum is met, judge retry semantics (once on transient, not on non-transient), per-member temperature overrides vs inherited request temperature, and prompt privacy (no panel model-name leakage).
Chat dispatch integration
crates/aisix-proxy/src/chat.rs, crates/aisix-proxy/src/dispatch.rs, crates/aisix-proxy/src/lib.rs
Skips single-target provider/bridge preflight validation for ensemble models via a new guard in require_provider. In dispatch, adds an early is_ensemble() branch that routes to a new dispatch_ensemble function before streaming/failover routing. dispatch_ensemble rejects ensemble requests containing tools or forcing tool_choice with 400, rejects stream: true requests, runs run_ensemble, and on error commits survivor panel tokens while emitting per-panel UsageEvents before returning mapped DispatchFailure. On success, aggregates panel+judge tokens once, emits per-panel (attempt_kind: "panel") and per-judge (attempt_kind: "judge") UsageEvents with re-resolved sub-model identifiers, evaluates output guardrails on the synthesized response (emits guardrail_blocked on Block), and returns Success with telemetry_handled_by_stream suppression. Declares ensemble module in lib.rs.
End-to-end integration tests
crates/aisix-proxy/src/lib.rs
Adds test helpers (direct_model_entry, ensemble_model_entry, ensemble_model_entry_min, mount_panel_and_judge) and comprehensive integration tests: successful fan-out + judge synthesis with client receiving synthesized answer under ensemble model name and judge usage; rejection of ensemble requests containing tools (400); rejection of stream: true (400); output guardrail blocking judge with correct per-sub-call usage telemetry (panel members and judge, guardrail_blocked set); min_responses shortfall mapping to 502 with usage telemetry only for surviving calls; empty tools: [] and tool_choice: "none" allowance; forced tool_choice objects rejected (400); misconfigured judge error messages do not leak identifiers; non-chat endpoints (e.g., /v1/embeddings) reject ensemble with explicit error message; judge upstream 5xx collapses to 502 while billing panel members and emitting no judge usage when judge fails.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant dispatch as chat.rs<br/>dispatch
participant dispatch_ensemble as dispatch_ensemble
participant run_ensemble as run_ensemble
participant ProxyModelCaller as ProxyModelCaller
rect rgba(70, 130, 180, 0.5)
Note over dispatch,ProxyModelCaller: Panel fan-out
Client->>dispatch: POST /v1/chat/completions<br/>(ensemble model)
dispatch->>dispatch: is_ensemble() check
dispatch->>dispatch_ensemble: validate tools,<br/>stream, ensemble config
dispatch_ensemble->>run_ensemble: run_ensemble(req, config, caller)
run_ensemble->>ProxyModelCaller: call(panel_member, panel_request) ×N
ProxyModelCaller-->>run_ensemble: collect successes
run_ensemble->>run_ensemble: enforce<br/>min_responses_or_default()
end
rect rgba(60, 179, 113, 0.5)
Note over run_ensemble,ProxyModelCaller: Judge synthesis with retry
run_ensemble->>run_ensemble: build judge_request<br/>with labeled candidates
run_ensemble->>ProxyModelCaller: call_judge_with_retry(judge)
ProxyModelCaller-->>run_ensemble: EnsembleOutcome
end
rect rgba(220, 100, 60, 0.5)
Note over dispatch_ensemble,Client: Guardrails + telemetry
dispatch_ensemble->>dispatch_ensemble: emit UsageEvents<br/>per panel member & judge
dispatch_ensemble->>dispatch_ensemble: evaluate output guardrails
dispatch_ensemble-->>Client: synthesized ChatResponse<br/>(or 400/502/ContentFiltered)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

An independent audit of #604 found a client-facing leak, a CI-blocking
schema drift, and billing gaps on the ensemble error exits.
- Redact ensemble member identity from client errors: a misconfigured
judge's display_name + provider_key_id no longer reach error.message
(the resolve_provider_key path still forwarded it). Detail kept in logs.
- Regenerate ensemble/model schema JSON so the schema-drift CI gate
passes (the timeout_ms doc edit hadn't been regenerated).
- Bill the panel on every error exit, not just success. Panel members
that already round-tripped upstream now emit usage + commit tokens on
the insufficient-panel (502) and judge-failure paths, matching the
output-guardrail-block path. EnsembleError carries the surviving
PanelOutcomes.
- tools: [] (empty) no longer 400s; only a non-empty tools array or a
forcing tool_choice rejects (many SDKs always send tools: []).
- Non-chat endpoints reject an ensemble model with an explicit, accurate
400 (was incidental and said "routing"); added coverage.
- Remove dead EnsembleConfig::is_empty().
Tests: +6 e2e (judge-error redaction, 502-path + judge-failure panel
billing, tools:[] accepted, non-chat 400). 259 core + 439 proxy green;
clippy -D warnings clean; schema-drift gate clean.
Refs #601.
@moonming

Copy link
Copy Markdown
MemberAuthor

Post-PR independent audit — all findings resolved (917d692)

Per our merge-gate, an independent cold-review agent audited this PR. Findings and resolutions:

HIGH

  • Client-facing leak — a misconfigured judge's display_name + provider_key_id reached the client error.message (the earlier redaction only covered the get_by_name path; resolve_provider_key still forwarded it). Fixed — redacted; detail kept in server logs. Test: ensemble_misconfigured_judge_does_not_leak_internal_config.
  • Schema-drift CI would be red — the timeout_ms doc edit wasn't regenerated into the schema JSON. Fixed — regenerated; dump-schema + git diff --exit-code schemas/ clean.

MEDIUM

  • Billing under-report on error exits — panel members that already round-tripped upstream weren't billed on the insufficient-panel (502) path. Fixed — emit usage + commit tokens for the survivors on the 502 path, matching the output-guardrail-block path. Also extended to the judge-failure path (same class, surfaced during the fix). Tests: ensemble_insufficient_panel_returns_502, ensemble_judge_failure_still_bills_panel.
  • tools: [] wrongly 400'd — empty arrays (which many SDKs always send) were rejected. Fixed — only a non-empty tools or a forcing tool_choice rejects. Tests: ensemble_allows_empty_tools_array, ensemble_allows_tool_choice_none.
  • Non-chat rejection incidental + misleading — non-chat endpoints rejected ensemble models with a "routing" message and no test. Fixed — explicit, accurate 400. Test: ensemble_model_on_embeddings_returns_400_with_explicit_message.

Verified clean by the audit: the 3-way schema XOR (direct | routing | ensemble), single token-commit, double-emit suppression, response-body redaction, and the cross-repo wire contract vs api7/AISIX-Cloud#804.

Tracked follow-ups (not blocking this PR):

  • Total judge-prompt budget cap (per-candidate truncation exists; a panel-wide cap folds into the hardening PR under [DP] ensemble model — parallel panel fan-out + judge synthesis #601).
  • [CP] the dashboard should recognize the new attempt_kind values panel/judge (free-string on the wire — won't break ingestion) — tracked under api7/AISIX-Cloud#804.

259 core + 439 proxy tests green; clippy --all-targets -- -D warnings clean; schema-drift gate clean.

@moonming
moonming merged commit ca2542e into mainJun 15, 2026
10 checks passed
@moonming
moonming deleted the feat/ensemble-model branch June 15, 2026 03:15
moonming added a commit that referenced this pull request Jun 15, 2026
Streams the judge's synthesized answer for ensemble models (stream:true). Panel buffers non-streaming, then the judge's tokens stream via the existing build_sse_stream (reused unmodified). Independently audited: safe to merge. Follows #604; tracked in #601.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DP] ensemble model — parallel panel fan-out + judge synthesis

1 participant

@moonming