1use crate::providers::ModelProvider;
9use serde::{Deserialize, Serialize};
10
11pub const DEFAULT_MODEL_ROUTER_ID: &str = "enact/model-router";
13
14#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct RoutingPolicy {
38 pub profile: RoutingProfile,
40 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#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct RoutingDecision {
56 pub requested_model: String,
58 pub logical_model: String,
60 pub concrete_model: String,
62 pub profile: RoutingProfile,
64 pub confidence: f32,
66 pub used_default_router: bool,
68 pub rationale: String,
70 pub source: ModelSelectionSource,
72}
73
74pub 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
96pub struct ModelRouter;
98
99impl ModelRouter {
100 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}