feat(api): POST /api/topologies/compare (provider-backed)
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:
co-authored by
Claude Opus 4.8
parent
fa7e5dec5f
commit
a35c82d860
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user