1mod elo;
4
5pub use elo::{global_elo, EloTable};
6
7use aria_router_config::{DecisionCfg, RouterDocument};
8use aria_router_core::{RouterError, ModelCard};
9
10#[derive(Debug, Clone, Default)]
11pub struct RuntimeStats {
12 pub latency_ms: std::collections::HashMap<String, f32>,
13 pub load: std::collections::HashMap<String, f32>,
14 pub cost: std::collections::HashMap<String, f32>,
15 pub elo: std::collections::HashMap<String, f32>,
17}
18
19pub fn select(
20 _doc: &RouterDocument,
21 decision: &DecisionCfg,
22 eligible: &[ModelCard],
23 stats: &RuntimeStats,
24) -> Result<String, RouterError> {
25 if eligible.is_empty() {
26 return Err(RouterError::FailClosed("no eligible models".into()));
27 }
28 let algo = decision.algorithm.as_deref().unwrap_or("static");
29 if RouterDocument::unimplemented_algorithm(algo) {
30 return Err(RouterError::Unsupported(format!("algorithm {algo} not implemented")));
31 }
32 let names: Vec<String> = if decision.model_refs.is_empty() {
33 eligible.iter().map(|m| m.name.clone()).collect()
34 } else {
35 decision
36 .model_refs
37 .iter()
38 .map(|r| r.model.clone())
39 .filter(|n| eligible.iter().any(|e| e.name == *n))
40 .collect()
41 };
42 if names.is_empty() {
43 return Err(RouterError::FailClosed(
44 "decision modelRefs not in eligible pool".into(),
45 ));
46 }
47 match algo {
48 "static" => Ok(names[0].clone()),
49 "latency-aware" | "latency_aware" => {
50 let best = names
51 .iter()
52 .min_by(|a, b| {
53 let la = stats.latency_ms.get(*a).copied().unwrap_or(1000.0);
54 let lb = stats.latency_ms.get(*b).copied().unwrap_or(1000.0);
55 la.partial_cmp(&lb).unwrap_or(std::cmp::Ordering::Equal)
56 })
57 .cloned()
58 .unwrap();
59 Ok(best)
60 }
61 "multi-factor" | "multi_factor" => {
62 let best = names
63 .iter()
64 .min_by(|a, b| {
65 let sa = score(a, stats);
66 let sb = score(b, stats);
67 sa.partial_cmp(&sb).unwrap_or(std::cmp::Ordering::Equal)
68 })
69 .cloned()
70 .unwrap();
71 Ok(best)
72 }
73 "elo" | "ratings" => {
74 let best = names
75 .iter()
76 .max_by(|a, b| {
77 let ra = stats
78 .elo
79 .get(*a)
80 .copied()
81 .unwrap_or_else(|| global_elo().rating(a));
82 let rb = stats
83 .elo
84 .get(*b)
85 .copied()
86 .unwrap_or_else(|| global_elo().rating(b));
87 ra.partial_cmp(&rb).unwrap_or(std::cmp::Ordering::Equal)
88 })
89 .cloned()
90 .unwrap();
91 Ok(best)
92 }
93 other => Err(RouterError::Unsupported(format!("algorithm {other}"))),
94 }
95}
96
97fn score(name: &str, stats: &RuntimeStats) -> f32 {
98 let lat = stats.latency_ms.get(name).copied().unwrap_or(100.0);
99 let load = stats.load.get(name).copied().unwrap_or(0.0);
100 let cost = stats.cost.get(name).copied().unwrap_or(1.0);
101 lat * 0.5 + load * 20.0 + cost * 10.0
102}
103
104pub fn hard_filter(
105 doc: &RouterDocument,
106 names: &[String],
107 require_locality: Option<&str>,
108 require_modality: Option<&str>,
109) -> Vec<ModelCard> {
110 names
111 .iter()
112 .filter_map(|n| doc.provider(n))
113 .filter(|p| {
114 if let Some(loc) = require_locality {
115 if p.locality != loc {
116 return false;
117 }
118 }
119 if let Some(mod_) = require_modality {
120 if p.modality != mod_ && p.modality != "any" {
121 return false;
122 }
123 }
124 true
125 })
126 .map(|p| ModelCard {
127 name: p.name.clone(),
128 locality: p.locality.clone(),
129 modality: p.modality.clone(),
130 capabilities: p.capabilities.clone(),
131 provider_model_id: p.provider_model_id.clone(),
132 tier: p.tier.clone(),
133 })
134 .collect()
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 fn cards() -> Vec<ModelCard> {
142 vec![
143 ModelCard {
144 name: "a".into(),
145 locality: "local".into(),
146 modality: "text".into(),
147 capabilities: vec!["chat".into()],
148 provider_model_id: "a".into(),
149 tier: None,
150 },
151 ModelCard {
152 name: "b".into(),
153 locality: "local".into(),
154 modality: "text".into(),
155 capabilities: vec!["chat".into()],
156 provider_model_id: "b".into(),
157 tier: None,
158 },
159 ]
160 }
161
162 fn decision(algo: &str) -> DecisionCfg {
163 DecisionCfg {
164 name: "d".into(),
165 description: None,
166 priority: 1,
167 rules: Default::default(),
168 model_refs: vec![
169 aria_router_config::ModelRef { model: "a".into() },
170 aria_router_config::ModelRef { model: "b".into() },
171 ],
172 algorithm: Some(algo.into()),
173 plugins: vec![],
174 locality: None,
175 emits: vec![],
176 }
177 }
178
179 fn doc() -> RouterDocument {
180 RouterDocument::from_yaml_str(
181 r#"
182version: v0.3
183providers:
184 models:
185 - name: a
186 locality: local
187 backend_refs: [{name: p, endpoint: 127.0.0.1:1}]
188 - name: b
189 locality: local
190 backend_refs: [{name: p, endpoint: 127.0.0.1:2}]
191entrypoints:
192 - model_names: [auto]
193 router: semantic
194 recipe: r
195recipes:
196 - name: r
197 router: semantic
198 routing:
199 decisions:
200 - name: d
201 rules: { operator: AND, conditions: [] }
202 modelRefs: [{model: a}]
203"#,
204 )
205 .unwrap()
206 }
207
208 #[test]
209 fn static_first() {
210 let d = doc();
211 let got = select(&d, &decision("static"), &cards(), &RuntimeStats::default()).unwrap();
212 assert_eq!(got, "a");
213 }
214
215 #[test]
216 fn latency_aware_picks_faster() {
217 let d = doc();
218 let mut stats = RuntimeStats::default();
219 stats.latency_ms.insert("a".into(), 200.0);
220 stats.latency_ms.insert("b".into(), 10.0);
221 let got = select(&d, &decision("latency-aware"), &cards(), &stats).unwrap();
222 assert_eq!(got, "b");
223 }
224
225 #[test]
226 fn multi_factor_picks_cheaper() {
227 let d = doc();
228 let mut stats = RuntimeStats::default();
229 stats.cost.insert("a".into(), 9.0);
230 stats.cost.insert("b".into(), 1.0);
231 let got = select(&d, &decision("multi-factor"), &cards(), &stats).unwrap();
232 assert_eq!(got, "b");
233 }
234
235 #[test]
236 fn elo_picks_higher_rating() {
237 let d = doc();
238 let mut stats = RuntimeStats::default();
239 stats.elo.insert("a".into(), 900.0);
240 stats.elo.insert("b".into(), 1200.0);
241 let got = select(&d, &decision("elo"), &cards(), &stats).unwrap();
242 assert_eq!(got, "b");
243 }
244
245 #[test]
246 fn unimplemented_algorithm() {
247 let d = doc();
248 let err = select(&d, &decision("knn"), &cards(), &RuntimeStats::default()).unwrap_err();
249 assert!(matches!(err, RouterError::Unsupported(_)));
250 }
251}