1use serde::{Deserialize, Serialize};
24use std::collections::BTreeMap;
25
26#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
27#[serde(deny_unknown_fields)]
28pub struct Seat {
29 pub id: String,
30 pub node: String,
31 pub provider: String,
32 #[serde(default)]
33 pub account: Option<String>,
34}
35
36#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
37#[serde(deny_unknown_fields)]
38pub struct Role {
39 pub preset: Vec<String>,
40 #[serde(default)]
41 pub auto: bool,
42}
43
44#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
45#[serde(deny_unknown_fields)]
46pub struct AdvisorPolicy {
47 #[serde(default = "d_interval")]
48 pub min_interval_secs: u64,
49 #[serde(default = "d_dedup")]
50 pub dedup_secs: u64,
51}
52fn d_interval() -> u64 { 300 }
53fn d_dedup() -> u64 { 3600 }
54impl Default for AdvisorPolicy {
55 fn default() -> Self { Self { min_interval_secs: d_interval(), dedup_secs: d_dedup() } }
56}
57
58#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
59#[serde(deny_unknown_fields)]
60pub struct RoutingConfig {
61 #[serde(default)]
62 pub seats: Vec<Seat>,
63 #[serde(default)]
64 pub roles: BTreeMap<String, Role>,
65 #[serde(default)]
66 pub limit_cooldown_secs: Option<u64>,
67 #[serde(default)]
68 pub advisor: AdvisorPolicy,
69}
70
71#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
73pub struct InventoryModel {
74 pub id: String,
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub efforts: Vec<String>,
77 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
78 pub default: bool,
79}
80
81#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
83pub struct ProviderInventory {
84 pub provider: String,
85 #[serde(default)]
86 pub source: String,
87 #[serde(default)]
88 pub models: Vec<InventoryModel>,
89}
90
91#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
93pub struct NodeInventory {
94 pub node: String,
95 pub provider: String,
96 pub source: String,
97 pub models: Vec<InventoryModel>,
98 pub updated_at_ms: u64,
99}
100
101fn ok_name(x: &str) -> bool {
102 !x.is_empty() && x.len() <= 128 && x.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.[]".contains(&b))
103}
104
105pub fn ok_model_id(x: &str) -> bool {
107 !x.is_empty() && x.len() <= 128 && !x.starts_with(['/', ':']) && !x.contains("..") && x.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.[]/:@".contains(&b))
108}
109
110pub fn clean_report(providers: Vec<ProviderInventory>) -> Result<Vec<ProviderInventory>, &'static str> {
113 if providers.len() > 16 {
114 return Err("too many providers");
115 }
116 let mut out = Vec::new();
117 for mut p in providers {
118 if !ok_name(&p.provider) || p.source.len() > 32 {
119 tracing::warn!(provider = %p.provider.chars().take(40).collect::<String>(), "inventory: provider entry dropped");
120 continue;
121 }
122 let before = p.models.len();
123 p.models.retain(|m| ok_model_id(&m.id));
124 p.models.truncate(256);
125 for m in &mut p.models {
126 m.efforts.retain(|e| ok_name(e));
127 m.efforts.truncate(16);
128 }
129 if p.models.len() < before {
130 tracing::warn!(provider = %p.provider, dropped = before - p.models.len(), "inventory: bad model entries dropped");
131 }
132 out.push(p);
133 }
134 Ok(out)
135}
136
137impl RoutingConfig {
138 pub fn from_store(inventory: &[NodeInventory], roles: BTreeMap<String, Role>) -> Self {
140 let seats = inventory
141 .iter()
142 .map(|i| Seat { id: format!("{}/{}", i.node, i.provider), node: i.node.clone(), provider: i.provider.clone(), account: None })
143 .collect();
144 Self { seats, roles, limit_cooldown_secs: None, advisor: AdvisorPolicy::default() }
145 }
146 fn cooldown_ms(&self) -> u64 {
147 self.limit_cooldown_secs.unwrap_or(3600) * 1000
148 }
149}
150
151#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
153pub struct ModelRef {
154 pub provider: String,
155 pub model: String,
156 pub effort: Option<String>,
157}
158
159pub fn parse_model_ref(s: &str) -> Option<ModelRef> {
160 let (provider, rest) = s.split_once('/')?;
161 let ok = |x: &str| !x.is_empty() && x.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b));
162 let (model, effort) = match rest.rsplit_once(':') {
165 Some((m, e)) if ok(e) && !m.is_empty() => (m, Some(e.to_owned())),
166 Some((m, "")) => (m, None),
167 _ => (rest, None),
168 };
169 (ok(provider) && ok_model_id(model) && effort.as_deref().is_none_or(ok)).then(|| ModelRef { provider: provider.into(), model: model.into(), effort })
170}
171
172#[derive(Clone, Debug, Default, Serialize, PartialEq, Eq)]
174pub struct SeatState {
175 pub limited_until_ms: Option<u64>,
177 pub hint: Option<String>,
178}
179
180pub fn fold_signals<'a>(signals: impl IntoIterator<Item = &'a crate::tasks::SessionSignal>, cfg: &RoutingConfig) -> BTreeMap<(String, String), SeatState> {
182 let mut out: BTreeMap<(String, String), SeatState> = BTreeMap::new();
183 for s in signals {
184 let key = (s.node_id.clone(), s.provider.clone());
185 match s.kind.as_str() {
186 "limit" => {
187 let until = s.hint.as_deref().and_then(|h| parse_reset(h, s.at_ms)).unwrap_or(s.at_ms + cfg.cooldown_ms());
188 out.insert(key, SeatState { limited_until_ms: Some(until), hint: s.hint.clone() });
189 }
190 "ready" => {
191 out.insert(key, SeatState::default());
192 }
193 _ => {}
194 }
195 }
196 out
197}
198
199pub fn parse_reset(hint: &str, at_ms: u64) -> Option<u64> {
201 let lower = hint.to_lowercase();
202 let idx = lower.find(" in ")?;
203 let mut words = lower[idx + 4..].split_whitespace();
204 let n: f64 = words.next()?.parse().ok()?;
205 let unit = words.next()?;
206 let secs = if unit.starts_with("min") { 60.0 } else if unit.starts_with("hour") || unit == "h" || unit.starts_with("hr") { 3600.0 } else if unit.starts_with("day") { 86400.0 } else { return None };
207 let ms = (n * secs * 1000.0) as u64;
208 (ms > 0 && ms <= 8 * 86_400_000).then_some(at_ms + ms)
209}
210
211#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
212pub struct Candidate {
213 pub seat: Seat,
214 pub model: ModelRef,
215 pub available: bool,
216 pub limited_until_ms: Option<u64>,
217 pub hint: Option<String>,
218}
219
220#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
221pub struct Recommendation {
222 pub role: String,
223 pub pick: Option<Candidate>,
225 pub auto: bool,
227 pub fallback_from: Option<Candidate>,
229 pub candidates: Vec<Candidate>,
230}
231
232pub fn offered(inventory: &[NodeInventory], m: &ModelRef) -> bool {
234 inventory.iter().any(|i| {
235 i.provider == m.provider
236 && i.models.iter().any(|x| x.id == m.model && m.effort.as_ref().is_none_or(|e| x.efforts.iter().any(|y| y == e)))
237 })
238}
239
240pub fn recommend(cfg: &RoutingConfig, state: &BTreeMap<(String, String), SeatState>, role: &str, now_ms: u64) -> Result<Recommendation, String> {
241 recommend_in(cfg, None, state, role, now_ms)
242}
243
244pub fn recommend_in(cfg: &RoutingConfig, inventory: Option<&[NodeInventory]>, state: &BTreeMap<(String, String), SeatState>, role: &str, now_ms: u64) -> Result<Recommendation, String> {
246 let r = cfg.roles.get(role).ok_or("unknown role")?;
247 let mut candidates = Vec::new();
248 for entry in &r.preset {
249 let model = parse_model_ref(entry).ok_or_else(|| format!("bad preset entry: {entry}"))?;
250 let has = |seat: &Seat| {
251 inventory.is_none_or(|inv| {
252 inv.iter().any(|i| i.node == seat.node && i.provider == seat.provider && i.models.iter().any(|x| x.id == model.model))
253 })
254 };
255 for seat in cfg.seats.iter().filter(|s| s.provider == model.provider && has(s)) {
256 let st = state.get(&(seat.node.clone(), seat.provider.clone())).cloned().unwrap_or_default();
257 let limited = st.limited_until_ms.filter(|u| *u > now_ms);
258 candidates.push(Candidate { seat: seat.clone(), model: model.clone(), available: limited.is_none(), limited_until_ms: limited, hint: limited.and(st.hint) });
259 }
260 }
261 let pick = candidates
262 .iter()
263 .find(|c| c.available)
264 .or_else(|| candidates.iter().min_by_key(|c| c.limited_until_ms.unwrap_or(u64::MAX)))
265 .cloned();
266 let fallback_from = match (&pick, candidates.first()) {
267 (Some(p), Some(first)) if p != first => Some(first.clone()),
268 _ => None,
269 };
270 Ok(Recommendation { role: role.into(), pick, auto: r.auto, fallback_from, candidates })
271}
272
273#[derive(Default)]
276pub struct AdviceGate {
277 last: BTreeMap<String, u64>,
278 topics: BTreeMap<(String, String), u64>,
279}
280
281impl AdviceGate {
282 pub fn check(&mut self, policy: &AdvisorPolicy, advisor: &str, topic: &str, now_ms: u64) -> Result<(), &'static str> {
283 let topic = topic.trim().to_lowercase();
284 if topic.is_empty() {
285 return Err("topic required");
286 }
287 if self.last.get(advisor).is_some_and(|t| now_ms < t + policy.min_interval_secs * 1000) {
288 return Err("too soon");
289 }
290 let key = (advisor.to_owned(), topic);
291 if self.topics.get(&key).is_some_and(|t| now_ms < t + policy.dedup_secs * 1000) {
292 return Err("duplicate topic");
293 }
294 self.last.insert(advisor.to_owned(), now_ms);
295 self.topics.insert(key, now_ms);
296 let horizon = now_ms.saturating_sub(policy.dedup_secs * 1000);
297 self.topics.retain(|_, t| *t >= horizon);
298 Ok(())
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 #[test]
305 fn model_ids_with_paths_and_one_bad_entry() {
306 let r = super::parse_model_ref("kimi/kimi-code/k3:high").unwrap();
307 assert_eq!((r.model.as_str(), r.effort.as_deref()), ("kimi-code/k3", Some("high")));
308 let p: Vec<super::ProviderInventory> = serde_json::from_str(r#"[{"provider":"kimi","models":[{"id":"kimi-code/k3"},{"id":"bad id"}]},{"provider":"bad provider"}]"#).unwrap();
309 let c = super::clean_report(p).unwrap();
310 assert_eq!(c.len(), 1);
311 assert_eq!(c[0].models.len(), 1);
312 }
313 use super::*;
314 use crate::tasks::SessionSignal;
315
316 fn cfg() -> RoutingConfig {
317 serde_json::from_value(serde_json::json!({
318 "seats": [
319 { "id": "a-claude", "node": "a", "provider": "claude" },
320 { "id": "b-claude", "node": "b", "provider": "claude" },
321 { "id": "a-codex", "node": "a", "provider": "codex" }
322 ],
323 "roles": {
324 "dev": { "preset": ["claude/opus:high", "codex/gpt-5:high"], "auto": true },
325 "advisor": { "preset": ["codex/gpt-5:medium"] }
326 },
327 "limit_cooldown_secs": 600
328 }))
329 .unwrap()
330 }
331
332 fn sig(id: i64, node: &str, provider: &str, kind: &str, hint: Option<&str>, at: u64) -> SessionSignal {
333 SessionSignal { id, node_id: node.into(), workspace_id: "w".into(), instance_id: 1, provider: provider.into(), kind: kind.into(), hint: hint.map(Into::into), at_ms: at }
334 }
335
336 #[test]
337 fn parses_model_refs() {
338 assert_eq!(parse_model_ref("claude/opus:high").unwrap().effort.as_deref(), Some("high"));
339 assert_eq!(parse_model_ref("codex/gpt-5").unwrap().effort, None);
340 assert!(parse_model_ref("bad").is_none());
341 assert!(parse_model_ref("a/b c").is_none());
342 }
343
344 #[test]
345 fn picks_first_free_seat_and_falls_back_on_limit() {
346 let c = cfg();
347 let r = recommend(&c, &BTreeMap::new(), "dev", 0).unwrap();
348 assert_eq!(r.pick.as_ref().unwrap().seat.id, "a-claude");
349 assert!(r.auto && r.fallback_from.is_none());
350 let sigs = [sig(1, "a", "claude", "limit", Some("usage limit, try again in 2 hours"), 1000)];
351 let st = fold_signals(&sigs, &c);
352 let r = recommend(&c, &st, "dev", 5000).unwrap();
353 assert_eq!(r.pick.as_ref().unwrap().seat.id, "b-claude");
354 assert_eq!(r.fallback_from.as_ref().unwrap().seat.id, "a-claude");
355 assert_eq!(r.candidates[0].limited_until_ms, Some(1000 + 7_200_000));
356 let r = recommend(&c, &st, "dev", 1000 + 7_200_001).unwrap();
358 assert_eq!(r.pick.unwrap().seat.id, "a-claude");
359 }
360
361 #[test]
362 fn ready_clears_and_cooldown_applies_without_hint() {
363 let c = cfg();
364 let sigs = [sig(1, "a", "claude", "limit", None, 0), sig(2, "b", "claude", "limit", None, 0)];
365 let st = fold_signals(&sigs, &c);
366 let r = recommend(&c, &st, "dev", 1).unwrap();
367 assert_eq!(r.pick.as_ref().unwrap().seat.provider, "codex");
368 let sigs2 = [sig(1, "a", "claude", "limit", None, 0), sig(2, "b", "claude", "limit", None, 10), sig(3, "a", "codex", "limit", None, 20)];
370 let r = recommend(&c, &fold_signals(&sigs2, &c), "dev", 30).unwrap();
371 assert_eq!(r.pick.as_ref().unwrap().seat.id, "a-claude");
372 assert!(!r.pick.unwrap().available);
373 let sigs3 = [sig(1, "a", "claude", "limit", None, 0), sig(2, "a", "claude", "ready", None, 5)];
375 assert!(recommend(&c, &fold_signals(&sigs3, &c), "dev", 6).unwrap().pick.unwrap().available);
376 assert!(recommend(&c, &st, "dev", 600_001).unwrap().candidates.iter().all(|x| x.available));
378 }
379
380 #[test]
381 fn non_auto_role_and_unknown_role() {
382 let c = cfg();
383 assert!(!recommend(&c, &BTreeMap::new(), "advisor", 0).unwrap().auto);
384 assert!(recommend(&c, &BTreeMap::new(), "nope", 0).is_err());
385 }
386
387 #[test]
388 fn advice_gate_limits_noise() {
389 let p = AdvisorPolicy { min_interval_secs: 60, dedup_secs: 600 };
390 let mut g = AdviceGate::default();
391 assert!(g.check(&p, "adv", "tests failing", 0).is_ok());
392 assert_eq!(g.check(&p, "adv", "other", 1000), Err("too soon"));
393 assert_eq!(g.check(&p, "adv", "Tests failing ", 61_000), Err("duplicate topic"));
394 assert!(g.check(&p, "adv", "other", 61_000).is_ok());
395 assert!(g.check(&p, "adv2", "tests failing", 61_000).is_ok());
396 assert!(g.check(&p, "adv", "tests failing", 700_000).is_ok());
397 }
398}