switchyard_libsy/algorithms/
infra.rs1use crate::{LibsyError, Result, RoutingOutcome, RuntimeModels};
7use serde::Deserialize;
8use std::time::{Duration, Instant};
9use switchyard_protocol::{Category, InfraSignals, ModelId};
10
11#[derive(Clone, Debug, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct InfraPolicy {
15 pub max_signal_age_ms: u64,
17 pub min_waiting_requests: u64,
19 pub max_capable_inflight: u64,
21}
22
23impl InfraPolicy {
24 pub fn validate(&self) -> Result<()> {
26 if self.max_signal_age_ms == 0
27 || self.min_waiting_requests == 0
28 || self.max_capable_inflight == 0
29 {
30 return Err(LibsyError::AlgorithmError {
31 message: "infra requires positive limits".into(),
32 });
33 }
34 Ok(())
35 }
36
37 fn evaluate(
38 &self,
39 signals: &InfraSignals,
40 source: &ModelId,
41 destination: &ModelId,
42 ) -> Option<(bool, &'static str)> {
43 let source = signals.get(source)?.load.as_ref()?;
44 let destination = signals.get(destination)?;
45 let load = destination.load.as_ref()?;
46 let now = Instant::now();
47 if [source, load].iter().any(|pool| {
48 now.checked_duration_since(pool.sampled_at)
49 .is_none_or(|age| age > Duration::from_millis(self.max_signal_age_ms))
50 }) {
51 return None;
52 }
53 if source.min_waiting < self.min_waiting_requests {
54 return Some((false, "small_pool_not_queued"));
55 }
56 if load.max_waiting > 0
57 || load.running.max(destination.local_inflight) >= self.max_capable_inflight
58 {
59 return Some((false, "protect_capable_capacity"));
60 }
61 Some((true, "small_pool_queued"))
62 }
63
64 pub fn apply(
69 &self,
70 outcome: &mut RoutingOutcome,
71 models: &RuntimeModels,
72 signals: &InfraSignals,
73 ) -> Result<bool> {
74 if outcome.response.is_some()
75 || outcome
76 .request
77 .metadata
78 .as_ref()
79 .is_some_and(|m| m.is_subagent_work())
80 {
81 return Ok(false);
82 }
83 let ([efficient], [capable]) = (
84 models.models_for(&Category::Efficient),
85 models.models_for(&Category::Capable),
86 ) else {
87 return Err(LibsyError::AlgorithmError {
88 message: "infra requires one efficient and one capable model".into(),
89 });
90 };
91 if outcome.selected_model_id()? != efficient || efficient == capable {
92 return Ok(false);
93 }
94 let (is_spillover, reason) = self
95 .evaluate(signals, efficient, capable)
96 .unwrap_or((false, "unknown_or_stale_signals"));
97 tracing::info!(task_model = %efficient, alternate_model = %capable, spillover = is_spillover, reason,
98 "queue routing decision");
99 if is_spillover {
100 outcome.selected_model_ids.retain(|id| id != capable);
101 outcome.selected_model_ids.insert(0, capable.clone());
102 outcome.request.llm_request.model = Some(capable.to_string());
103 }
104 Ok(is_spillover)
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use switchyard_protocol::{ModelInfraSignals, PoolLoad, Request, text_request};
112
113 #[test]
114 fn queue_policy_preserves_task_choices() {
115 let policy = InfraPolicy {
116 max_signal_age_ms: 1000,
117 min_waiting_requests: 4,
118 max_capable_inflight: 2,
119 };
120 policy.validate().unwrap();
121 let models = RuntimeModels::new(
122 [
123 (Category::Efficient, vec!["small".into()]),
124 (Category::Capable, vec!["large".into()]),
125 ]
126 .into(),
127 );
128 let request = Request {
129 llm_request: text_request(None, "hello"),
130 ..Default::default()
131 };
132 for case in [
133 "busy",
134 "one_quiet",
135 "capable_queued",
136 "capable_running",
137 "local_limit",
138 "missing",
139 "stale",
140 "future",
141 "below_threshold",
142 "at_threshold",
143 "capable_task",
144 ] {
145 let model = |waiting| ModelInfraSignals {
146 load: Some(PoolLoad {
147 sampled_at: Instant::now(),
148 running: 0,
149 min_waiting: waiting,
150 max_waiting: waiting,
151 }),
152 ..Default::default()
153 };
154 let mut source = model(8);
155 let mut destination = model(0);
156 match case {
157 "one_quiet" => source.load.as_mut().unwrap().min_waiting = 0,
158 "capable_queued" => destination.load.as_mut().unwrap().max_waiting = 1,
159 "capable_running" => destination.load.as_mut().unwrap().running = 2,
160 "local_limit" => destination.local_inflight = 2,
161 "missing" => source.load = None,
162 "stale" => source.load.as_mut().unwrap().sampled_at -= Duration::from_secs(2),
163 "future" => destination.load.as_mut().unwrap().sampled_at += Duration::from_secs(2),
164 "below_threshold" => source.load.as_mut().unwrap().min_waiting = 3,
165 "at_threshold" => source.load.as_mut().unwrap().min_waiting = 4,
166 _ => {}
167 }
168 let signals = [
169 (ModelId::from("small"), source),
170 (ModelId::from("large"), destination),
171 ]
172 .into();
173 let initial = if case == "capable_task" {
174 "large"
175 } else {
176 "small"
177 };
178 let mut outcome = RoutingOutcome::route_to(initial.into(), vec![], request.clone());
179 let is_spillover = matches!(case, "busy" | "at_threshold");
180 assert_eq!(
181 policy.apply(&mut outcome, &models, &signals).unwrap(),
182 is_spillover,
183 "{case}"
184 );
185 assert_eq!(
186 outcome.selected_model_id().unwrap().as_str(),
187 if is_spillover { "large" } else { initial },
188 "{case}"
189 );
190 outcome.request.llm_request.model = None;
191 assert_eq!(outcome.request.llm_request, request.llm_request);
192 }
193 let mut invalid = policy;
194 invalid.min_waiting_requests = 0;
195 assert!(invalid.validate().is_err());
196 }
197}