feat(api): POST /api/topologies/compare (provider-backed)
ci / gates (push) Has been cancelled
ci / sandbox-k8s (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / e2e (push) Has been cancelled

Run a task across topologies via the API and return a leaderboard + quality/cost
Pareto front. The handler builds a ProviderExecutor + JudgeScorer over the
runtime's configured provider/model, then calls cm_orchestrator::compare.

- cm-runtime: expose provider()/model()/max_tokens() accessors on Runtime.
- cm-api: depend on cm-orchestrator (provider feature); add the compare route.
- Integration test runs a 2-topology comparison through the real server
  (scripted provider) → 200 with results + leaderboard. 4 topology tests green;
  offline build + clippy clean.

The topology endpoints (catalog/classify/build/compare) ship to gw-04 with the
upcoming ReactFlow UI in one server+frontend redeploy.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-15 22:28:39 -07:00
co-authored by Claude Opus 4.8
parent fa7e5dec5f
commit a35c82d860
6 changed files with 82 additions and 1 deletions
Generated
+1
View File
@@ -491,6 +491,7 @@ dependencies = [
"cm-db", "cm-db",
"cm-domain", "cm-domain",
"cm-llm", "cm-llm",
"cm-orchestrator",
"cm-runtime", "cm-runtime",
"cm-safety", "cm-safety",
"cm-scheduler", "cm-scheduler",
+1
View File
@@ -22,6 +22,7 @@ cm-billing = { path = "../cm-billing" }
cm-config = { path = "../cm-config" } cm-config = { path = "../cm-config" }
cm-db = { path = "../cm-db" } cm-db = { path = "../cm-db" }
cm-domain = { path = "../cm-domain" } cm-domain = { path = "../cm-domain" }
cm-orchestrator = { path = "../cm-orchestrator", features = ["provider"] }
cm-runtime = { path = "../cm-runtime" } cm-runtime = { path = "../cm-runtime" }
cm-safety = { path = "../cm-safety" } cm-safety = { path = "../cm-safety" }
cm-scheduler = { path = "../cm-scheduler" } cm-scheduler = { path = "../cm-scheduler" }
+1
View File
@@ -138,6 +138,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/topologies", get(routes::topology::catalog)) .route("/api/topologies", get(routes::topology::catalog))
.route("/api/topologies/classify", post(routes::topology::classify_graph)) .route("/api/topologies/classify", post(routes::topology::classify_graph))
.route("/api/topologies/build", post(routes::topology::build_graph)) .route("/api/topologies/build", post(routes::topology::build_graph))
.route("/api/topologies/compare", post(routes::topology::compare_topologies))
.layer(tower_http::trace::TraceLayer::new_for_http()) .layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state) .with_state(state)
} }
+29 -1
View File
@@ -3,11 +3,13 @@
//! back the topology builder UI. Running/comparing topologies is a later, //! back the topology builder UI. Running/comparing topologies is a later,
//! provider-backed endpoint. //! provider-backed endpoint.
use axum::extract::State;
use axum::Json; use axum::Json;
use cm_orchestrator::{compare, Comparison, JudgeScorer, ProviderExecutor};
use cm_topology::{build, classify, heuristics, Classification, TopologyGraph, TopologyKind}; use cm_topology::{build, classify, heuristics, Classification, TopologyGraph, TopologyKind};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ApiError, Authed}; use crate::{ApiError, AppState, Authed};
/// A role and its suggested share of the team. /// A role and its suggested share of the team.
#[derive(Serialize)] #[derive(Serialize)]
@@ -70,3 +72,29 @@ pub async fn build_graph(
let graph = build(req.kind, &roles).map_err(|_| ApiError::BadRequest)?; let graph = build(req.kind, &roles).map_err(|_| ApiError::BadRequest)?;
Ok(Json(graph)) Ok(Json(graph))
} }
/// Request body for running a multi-topology comparison.
#[derive(Deserialize)]
pub struct CompareRequest {
pub task: String,
pub graphs: Vec<TopologyGraph>,
}
/// `POST /api/topologies/compare` — run a task across the given topologies and
/// return a leaderboard + quality/cost Pareto front. Uses the configured
/// provider for both execution (tool-free turns) and the LLM judge.
pub async fn compare_topologies(
State(state): State<AppState>,
_auth: Authed,
Json(req): Json<CompareRequest>,
) -> Result<Json<Comparison>, ApiError> {
let provider = state.runtime.provider();
let model = state.runtime.model().to_string();
let executor =
ProviderExecutor::new(provider.clone(), model.clone(), state.runtime.max_tokens());
let scorer = JudgeScorer::new(provider, model, 16);
let cmp = compare(&req.graphs, &req.task, &executor, &scorer)
.await
.map_err(|_| ApiError::Internal)?;
Ok(Json(cmp))
}
+35
View File
@@ -336,6 +336,41 @@ async fn topology_build_returns_a_canonical_graph() {
assert_eq!(bad.status(), 400); assert_eq!(bad.status(), 400);
} }
#[tokio::test]
async fn topology_compare_runs_across_topologies() {
let pool = cm_testkit::test_pool().await;
seed(&pool).await;
let server = serve(pool).await;
let token = login(&server).await;
let res = server
.client
.post(format!("{}/api/topologies/compare", server.base))
.bearer_auth(&token)
.json(&json!({
"task": "draft a launch plan",
"graphs": [
{
"kind": "pipeline",
"nodes": [{"id": "a", "role": "researcher"}, {"id": "b", "role": "writer"}],
"edges": [{"from": "a", "to": "b", "kind": "pipes_to"}]
},
{
"kind": "swarm",
"nodes": [{"id": "x", "role": "writer"}, {"id": "y", "role": "coordinator"}],
"edges": []
}
]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let cmp: Value = res.json().await.unwrap();
assert_eq!(cmp["results"].as_array().unwrap().len(), 2);
assert_eq!(cmp["leaderboard"].as_array().unwrap().len(), 2);
}
#[tokio::test] #[tokio::test]
async fn topology_endpoints_require_auth() { async fn topology_endpoints_require_auth() {
let pool = cm_testkit::test_pool().await; let pool = cm_testkit::test_pool().await;
+15
View File
@@ -178,6 +178,21 @@ impl Runtime {
&self.inner.pool &self.inner.pool
} }
/// The shared LLM provider (used by the topology comparison endpoint).
pub fn provider(&self) -> Arc<dyn LlmProvider> {
self.inner.provider.clone()
}
/// The configured default model id.
pub fn model(&self) -> &str {
&self.inner.config.model
}
/// The configured per-call max output tokens.
pub fn max_tokens(&self) -> u32 {
self.inner.config.max_tokens
}
/// Live-attach to a run that is still streaming. /// Live-attach to a run that is still streaming.
pub async fn subscribe(&self, run_id: Uuid) -> Option<broadcast::Receiver<RunEventEnvelope>> { pub async fn subscribe(&self, run_id: Uuid) -> Option<broadcast::Receiver<RunEventEnvelope>> {
self.inner self.inner