1use crate::common::ValidationError;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum RoutingMode {
15 Explicit,
17 Auto,
19 Shadow,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct AutoRoutingConfig {
27 pub enabled: bool,
29 pub fallback_model: String,
31 pub fallback_provider: String,
33 #[serde(default)]
35 pub eligible_task_classes: Vec<String>,
36}
37
38impl Default for AutoRoutingConfig {
39 fn default() -> Self {
40 Self {
41 enabled: false,
42 fallback_model: "baseline".to_owned(),
43 fallback_provider: "baseline".to_owned(),
44 eligible_task_classes: Vec::new(),
45 }
46 }
47}
48
49impl AutoRoutingConfig {
50 pub fn validate(&self) -> Result<(), ValidationError> {
52 require_non_empty(&self.fallback_model, "fallback_model")?;
53 require_non_empty(&self.fallback_provider, "fallback_provider")?;
54 for task_class in &self.eligible_task_classes {
55 require_non_empty(task_class, "eligible_task_classes entry")?;
56 }
57 Ok(())
58 }
59
60 pub fn allows_task_class(&self, task_class: &str) -> bool {
62 self.enabled
63 && self
64 .eligible_task_classes
65 .iter()
66 .any(|candidate| candidate == task_class)
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct SchedulerGate {
74 pub min_evaluated_tasks: u64,
75 pub max_outcome_degradation_pct: f64,
76 pub min_cost_improvement_pct: f64,
77 pub require_holdout_evidence: bool,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct AutoRoutingEvidence {
84 pub evaluated_tasks: u64,
85 pub outcome_degradation_pct: f64,
86 pub cost_improvement_pct: f64,
87 pub has_holdout_evidence: bool,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct AutoRoutingRequest {
94 pub task_id: String,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub requested_model: Option<String>,
97 pub routing_mode: RoutingMode,
98}
99
100impl AutoRoutingRequest {
101 pub fn validate(&self) -> Result<(), ValidationError> {
103 require_non_empty(&self.task_id, "task_id")?;
104 if let Some(model) = &self.requested_model {
105 require_non_empty(model, "requested_model")?;
106 }
107 if self.routing_mode == RoutingMode::Explicit && self.requested_model.is_none() {
108 return Err(ValidationError::new(
109 "explicit routing requires requested_model",
110 ));
111 }
112 Ok(())
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct RoutingDecision {
120 pub selected_model: String,
121 pub selected_provider: String,
122 pub rationale_code: String,
123 pub fallback_plan: String,
124 pub confidence: f64,
126}
127
128impl RoutingDecision {
129 pub fn validate(&self) -> Result<(), ValidationError> {
131 require_non_empty(&self.selected_model, "selected_model")?;
132 require_non_empty(&self.selected_provider, "selected_provider")?;
133 require_non_empty(&self.rationale_code, "rationale_code")?;
134 require_non_empty(&self.fallback_plan, "fallback_plan")?;
135 validate_unit_interval(self.confidence, "confidence")
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct RoutingReceipt {
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub requested_model: Option<String>,
145 pub baseline_model: String,
146 pub selected_model: String,
147 pub decision_ref: String,
148 pub method_version: String,
149}
150
151impl RoutingReceipt {
152 pub fn validate(&self) -> Result<(), ValidationError> {
154 if let Some(model) = &self.requested_model {
155 require_non_empty(model, "requested_model")?;
156 }
157 require_non_empty(&self.baseline_model, "baseline_model")?;
158 require_non_empty(&self.selected_model, "selected_model")?;
159 require_non_empty(&self.decision_ref, "decision_ref")?;
160 require_non_empty(&self.method_version, "method_version")
161 }
162}
163
164fn require_non_empty(value: &str, field: &str) -> Result<(), ValidationError> {
165 if value.trim().is_empty() {
166 Err(ValidationError::new(format!("{field} must not be empty")))
167 } else {
168 Ok(())
169 }
170}
171
172fn validate_unit_interval(value: f64, field: &str) -> Result<(), ValidationError> {
173 if value.is_finite() && (0.0..=1.0).contains(&value) {
174 Ok(())
175 } else {
176 Err(ValidationError::new(format!(
177 "{field} must be finite and between 0 and 1"
178 )))
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn wire_types_round_trip() {
188 let config = AutoRoutingConfig {
189 enabled: true,
190 fallback_model: "baseline-model".to_owned(),
191 fallback_provider: "baseline-provider".to_owned(),
192 eligible_task_classes: vec!["coding".to_owned()],
193 };
194 let request = AutoRoutingRequest {
195 task_id: "task-1".to_owned(),
196 requested_model: Some("requested-model".to_owned()),
197 routing_mode: RoutingMode::Auto,
198 };
199 let decision = RoutingDecision {
200 selected_model: "selected-model".to_owned(),
201 selected_provider: "selected-provider".to_owned(),
202 rationale_code: "reference".to_owned(),
203 fallback_plan: "baseline-plan".to_owned(),
204 confidence: 0.8,
205 };
206 let receipt = RoutingReceipt {
207 requested_model: request.requested_model.clone(),
208 baseline_model: config.fallback_model.clone(),
209 selected_model: decision.selected_model.clone(),
210 decision_ref: "decision-1".to_owned(),
211 method_version: "v1".to_owned(),
212 };
213
214 let config_json = serde_json::to_string(&config).expect("config serializes");
215 let request_json = serde_json::to_string(&request).expect("request serializes");
216 let decision_json = serde_json::to_string(&decision).expect("decision serializes");
217 let receipt_json = serde_json::to_string(&receipt).expect("receipt serializes");
218 assert_eq!(
219 serde_json::from_str::<AutoRoutingConfig>(&config_json).unwrap(),
220 config
221 );
222 assert_eq!(
223 serde_json::from_str::<AutoRoutingRequest>(&request_json).unwrap(),
224 request
225 );
226 assert_eq!(
227 serde_json::from_str::<RoutingDecision>(&decision_json).unwrap(),
228 decision
229 );
230 assert_eq!(
231 serde_json::from_str::<RoutingReceipt>(&receipt_json).unwrap(),
232 receipt
233 );
234 config.validate().expect("valid config");
235 request.validate().expect("valid request");
236 decision.validate().expect("valid decision");
237 receipt.validate().expect("valid receipt");
238 }
239
240 #[test]
241 fn structural_validation_rejects_invalid_values() {
242 let request = AutoRoutingRequest {
243 task_id: "task-1".to_owned(),
244 requested_model: None,
245 routing_mode: RoutingMode::Explicit,
246 };
247 assert!(request.validate().is_err());
248
249 let decision = RoutingDecision {
250 selected_model: "model".to_owned(),
251 selected_provider: "provider".to_owned(),
252 rationale_code: "reason".to_owned(),
253 fallback_plan: "baseline".to_owned(),
254 confidence: 1.1,
255 };
256 assert!(decision.validate().is_err());
257 }
258}