use crate::common::ValidationError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RoutingMode {
Explicit,
Auto,
Shadow,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutoRoutingConfig {
pub enabled: bool,
pub fallback_model: String,
pub fallback_provider: String,
#[serde(default)]
pub eligible_task_classes: Vec<String>,
}
impl Default for AutoRoutingConfig {
fn default() -> Self {
Self {
enabled: false,
fallback_model: "baseline".to_owned(),
fallback_provider: "baseline".to_owned(),
eligible_task_classes: Vec::new(),
}
}
}
impl AutoRoutingConfig {
pub fn validate(&self) -> Result<(), ValidationError> {
require_non_empty(&self.fallback_model, "fallback_model")?;
require_non_empty(&self.fallback_provider, "fallback_provider")?;
for task_class in &self.eligible_task_classes {
require_non_empty(task_class, "eligible_task_classes entry")?;
}
Ok(())
}
pub fn allows_task_class(&self, task_class: &str) -> bool {
self.enabled
&& self
.eligible_task_classes
.iter()
.any(|candidate| candidate == task_class)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SchedulerGate {
pub min_evaluated_tasks: u64,
pub max_outcome_degradation_pct: f64,
pub min_cost_improvement_pct: f64,
pub require_holdout_evidence: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutoRoutingEvidence {
pub evaluated_tasks: u64,
pub outcome_degradation_pct: f64,
pub cost_improvement_pct: f64,
pub has_holdout_evidence: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutoRoutingRequest {
pub task_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_model: Option<String>,
pub routing_mode: RoutingMode,
}
impl AutoRoutingRequest {
pub fn validate(&self) -> Result<(), ValidationError> {
require_non_empty(&self.task_id, "task_id")?;
if let Some(model) = &self.requested_model {
require_non_empty(model, "requested_model")?;
}
if self.routing_mode == RoutingMode::Explicit && self.requested_model.is_none() {
return Err(ValidationError::new(
"explicit routing requires requested_model",
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RoutingDecision {
pub selected_model: String,
pub selected_provider: String,
pub rationale_code: String,
pub fallback_plan: String,
pub confidence: f64,
}
impl RoutingDecision {
pub fn validate(&self) -> Result<(), ValidationError> {
require_non_empty(&self.selected_model, "selected_model")?;
require_non_empty(&self.selected_provider, "selected_provider")?;
require_non_empty(&self.rationale_code, "rationale_code")?;
require_non_empty(&self.fallback_plan, "fallback_plan")?;
validate_unit_interval(self.confidence, "confidence")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RoutingReceipt {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_model: Option<String>,
pub baseline_model: String,
pub selected_model: String,
pub decision_ref: String,
pub method_version: String,
}
impl RoutingReceipt {
pub fn validate(&self) -> Result<(), ValidationError> {
if let Some(model) = &self.requested_model {
require_non_empty(model, "requested_model")?;
}
require_non_empty(&self.baseline_model, "baseline_model")?;
require_non_empty(&self.selected_model, "selected_model")?;
require_non_empty(&self.decision_ref, "decision_ref")?;
require_non_empty(&self.method_version, "method_version")
}
}
fn require_non_empty(value: &str, field: &str) -> Result<(), ValidationError> {
if value.trim().is_empty() {
Err(ValidationError::new(format!("{field} must not be empty")))
} else {
Ok(())
}
}
fn validate_unit_interval(value: f64, field: &str) -> Result<(), ValidationError> {
if value.is_finite() && (0.0..=1.0).contains(&value) {
Ok(())
} else {
Err(ValidationError::new(format!(
"{field} must be finite and between 0 and 1"
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_types_round_trip() {
let config = AutoRoutingConfig {
enabled: true,
fallback_model: "baseline-model".to_owned(),
fallback_provider: "baseline-provider".to_owned(),
eligible_task_classes: vec!["coding".to_owned()],
};
let request = AutoRoutingRequest {
task_id: "task-1".to_owned(),
requested_model: Some("requested-model".to_owned()),
routing_mode: RoutingMode::Auto,
};
let decision = RoutingDecision {
selected_model: "selected-model".to_owned(),
selected_provider: "selected-provider".to_owned(),
rationale_code: "reference".to_owned(),
fallback_plan: "baseline-plan".to_owned(),
confidence: 0.8,
};
let receipt = RoutingReceipt {
requested_model: request.requested_model.clone(),
baseline_model: config.fallback_model.clone(),
selected_model: decision.selected_model.clone(),
decision_ref: "decision-1".to_owned(),
method_version: "v1".to_owned(),
};
let config_json = serde_json::to_string(&config).expect("config serializes");
let request_json = serde_json::to_string(&request).expect("request serializes");
let decision_json = serde_json::to_string(&decision).expect("decision serializes");
let receipt_json = serde_json::to_string(&receipt).expect("receipt serializes");
assert_eq!(
serde_json::from_str::<AutoRoutingConfig>(&config_json).unwrap(),
config
);
assert_eq!(
serde_json::from_str::<AutoRoutingRequest>(&request_json).unwrap(),
request
);
assert_eq!(
serde_json::from_str::<RoutingDecision>(&decision_json).unwrap(),
decision
);
assert_eq!(
serde_json::from_str::<RoutingReceipt>(&receipt_json).unwrap(),
receipt
);
config.validate().expect("valid config");
request.validate().expect("valid request");
decision.validate().expect("valid decision");
receipt.validate().expect("valid receipt");
}
#[test]
fn structural_validation_rejects_invalid_values() {
let request = AutoRoutingRequest {
task_id: "task-1".to_owned(),
requested_model: None,
routing_mode: RoutingMode::Explicit,
};
assert!(request.validate().is_err());
let decision = RoutingDecision {
selected_model: "model".to_owned(),
selected_provider: "provider".to_owned(),
rationale_code: "reason".to_owned(),
fallback_plan: "baseline".to_owned(),
confidence: 1.1,
};
assert!(decision.validate().is_err());
}
}