Skip to main content

enact_core/routing/
mod.rs

1//! Routing primitives for model selection.
2//!
3//! V6 foundation:
4//! - `enact/model-router` is the canonical logical default model id.
5//! - Routing resolves logical model ids to concrete provider models.
6//! - Routing decisions are explicit and observable.
7
8use crate::providers::ModelProvider;
9use serde::{Deserialize, Serialize};
10
11/// Canonical default logical model id.
12pub const DEFAULT_MODEL_ROUTER_ID: &str = "enact/model-router";
13
14/// Where a selected model came from in the precedence chain.
15#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum ModelSelectionSource {
18    Step,
19    Workflow,
20    Agent,
21    DefaultRouter,
22}
23
24/// Routing profile hint.
25#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27pub enum RoutingProfile {
28    Eco,
29    #[default]
30    Balanced,
31    Quality,
32    Deterministic,
33}
34
35/// Runtime routing policy.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct RoutingPolicy {
38    /// Which profile is active.
39    pub profile: RoutingProfile,
40    /// Confidence value attached to deterministic local routing decisions.
41    pub default_confidence: f32,
42}
43
44impl Default for RoutingPolicy {
45    fn default() -> Self {
46        Self {
47            profile: RoutingProfile::Balanced,
48            default_confidence: 0.70,
49        }
50    }
51}
52
53/// Explainable routing decision.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct RoutingDecision {
56    /// Requested logical id from step/workflow/agent/runtime.
57    pub requested_model: String,
58    /// Logical model id after precedence resolution.
59    pub logical_model: String,
60    /// Concrete model id used by provider adapter.
61    pub concrete_model: String,
62    /// Selected profile.
63    pub profile: RoutingProfile,
64    /// Confidence score for this routing decision.
65    pub confidence: f32,
66    /// Whether decision flowed through `enact/model-router`.
67    pub used_default_router: bool,
68    /// Human-readable decision rationale.
69    pub rationale: String,
70    /// Source in the model precedence chain.
71    pub source: ModelSelectionSource,
72}
73
74/// Resolve a logical model id from precedence:
75/// step.model -> workflow.model -> agent.model -> enact/model-router
76pub fn resolve_model_precedence(
77    step_model: Option<&str>,
78    workflow_model: Option<&str>,
79    agent_model: Option<&str>,
80) -> (String, ModelSelectionSource) {
81    if let Some(model) = step_model {
82        return (model.to_string(), ModelSelectionSource::Step);
83    }
84    if let Some(model) = workflow_model {
85        return (model.to_string(), ModelSelectionSource::Workflow);
86    }
87    if let Some(model) = agent_model {
88        return (model.to_string(), ModelSelectionSource::Agent);
89    }
90    (
91        DEFAULT_MODEL_ROUTER_ID.to_string(),
92        ModelSelectionSource::DefaultRouter,
93    )
94}
95
96/// Stateless model router.
97pub struct ModelRouter;
98
99impl ModelRouter {
100    /// Resolve model request to an explicit routing decision.
101    pub fn resolve(
102        requested_model: Option<&str>,
103        provider: &dyn ModelProvider,
104        policy: &RoutingPolicy,
105    ) -> RoutingDecision {
106        let (requested, source) = resolve_model_precedence(requested_model, None, None);
107        let used_default_router = requested == DEFAULT_MODEL_ROUTER_ID;
108        let concrete_model = if used_default_router {
109            provider.model().to_string()
110        } else {
111            requested.clone()
112        };
113
114        let rationale = if used_default_router {
115            format!(
116                "No explicit model pin provided; resolved '{}' to provider default '{}'",
117                DEFAULT_MODEL_ROUTER_ID, concrete_model
118            )
119        } else {
120            format!("Explicit model pin '{}' selected", concrete_model)
121        };
122
123        RoutingDecision {
124            requested_model: requested.clone(),
125            logical_model: requested,
126            concrete_model,
127            profile: policy.profile,
128            confidence: policy.default_confidence,
129            used_default_router,
130            rationale,
131            source,
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::providers::{ChatRequest, ChatResponse};
140    use async_trait::async_trait;
141
142    struct MockProvider;
143
144    #[async_trait]
145    impl ModelProvider for MockProvider {
146        fn name(&self) -> &str {
147            "mock"
148        }
149
150        fn model(&self) -> &str {
151            "gpt-4o-mini"
152        }
153
154        async fn chat(&self, _request: ChatRequest) -> anyhow::Result<ChatResponse> {
155            anyhow::bail!("not used")
156        }
157    }
158
159    #[test]
160    fn resolves_to_default_router_when_unspecified() {
161        let provider = MockProvider;
162        let policy = RoutingPolicy::default();
163
164        let decision = ModelRouter::resolve(None, &provider, &policy);
165
166        assert_eq!(decision.logical_model, DEFAULT_MODEL_ROUTER_ID);
167        assert_eq!(decision.concrete_model, "gpt-4o-mini");
168        assert!(decision.used_default_router);
169    }
170
171    #[test]
172    fn preserves_explicit_model_pin() {
173        let provider = MockProvider;
174        let policy = RoutingPolicy::default();
175
176        let decision = ModelRouter::resolve(Some("anthropic/claude-sonnet-4"), &provider, &policy);
177
178        assert_eq!(decision.logical_model, "anthropic/claude-sonnet-4");
179        assert_eq!(decision.concrete_model, "anthropic/claude-sonnet-4");
180        assert!(!decision.used_default_router);
181        assert_eq!(decision.source, ModelSelectionSource::Step);
182    }
183
184    #[test]
185    fn model_precedence_order_is_step_then_workflow_then_agent_then_default() {
186        let (model, source) = resolve_model_precedence(
187            Some("step/model"),
188            Some("workflow/model"),
189            Some("agent/model"),
190        );
191        assert_eq!(model, "step/model");
192        assert_eq!(source, ModelSelectionSource::Step);
193
194        let (model, source) =
195            resolve_model_precedence(None, Some("workflow/model"), Some("agent/model"));
196        assert_eq!(model, "workflow/model");
197        assert_eq!(source, ModelSelectionSource::Workflow);
198
199        let (model, source) = resolve_model_precedence(None, None, Some("agent/model"));
200        assert_eq!(model, "agent/model");
201        assert_eq!(source, ModelSelectionSource::Agent);
202
203        let (model, source) = resolve_model_precedence(None, None, None);
204        assert_eq!(model, DEFAULT_MODEL_ROUTER_ID);
205        assert_eq!(source, ModelSelectionSource::DefaultRouter);
206    }
207}