1use super::*;
21
22use std::collections::HashMap;
23
24use leviath_providers::UnavailableReason;
25use serde::{Deserialize, Serialize};
26
27#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq)]
32pub struct CircuitPolicy {
33 pub failures_before_open: u32,
36 pub cooldown_secs: u64,
39}
40
41pub const DEFAULT_FAILURES_BEFORE_OPEN: u32 = 3;
47
48pub const DEFAULT_CIRCUIT_COOLDOWN_SECS: u64 = 300;
53
54impl Default for CircuitPolicy {
55 fn default() -> Self {
56 Self {
57 failures_before_open: DEFAULT_FAILURES_BEFORE_OPEN,
58 cooldown_secs: DEFAULT_CIRCUIT_COOLDOWN_SECS,
59 }
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Circuit {
67 pub consecutive_failures: u32,
69 pub opened_at: Option<i64>,
72 pub reason: UnavailableReason,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct ProviderCircuitState {
79 pub provider: String,
81 pub reason: UnavailableReason,
83 pub consecutive_failures: u32,
85 pub retry_in_secs: u64,
87}
88
89#[derive(Resource, Debug, Clone, Default)]
94pub struct ProviderCircuits(HashMap<String, Circuit>);
95
96impl ProviderCircuits {
97 pub fn record_failure(
102 &mut self,
103 provider: &str,
104 reason: UnavailableReason,
105 now: i64,
106 policy: &CircuitPolicy,
107 ) -> bool {
108 let entry = self.0.entry(provider.to_string()).or_insert(Circuit {
109 consecutive_failures: 0,
110 opened_at: None,
111 reason,
112 });
113 entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
114 entry.reason = reason;
115 if policy.failures_before_open == 0 {
116 return false; }
118 let was_open = entry.opened_at.is_some();
119 if entry.consecutive_failures >= policy.failures_before_open {
120 entry.opened_at = Some(now);
123 }
124 !was_open && entry.opened_at.is_some()
125 }
126
127 pub fn record_success(&mut self, provider: &str) {
129 self.0.remove(provider);
130 }
131
132 pub fn is_open(&self, provider: &str, now: i64, policy: &CircuitPolicy) -> bool {
137 self.0
138 .get(provider)
139 .and_then(|c| c.opened_at)
140 .is_some_and(|at| now.saturating_sub(at) < policy.cooldown_secs as i64)
141 }
142
143 pub fn open_circuits(&self, now: i64, policy: &CircuitPolicy) -> Vec<ProviderCircuitState> {
146 let mut open: Vec<ProviderCircuitState> = self
147 .0
148 .iter()
149 .filter_map(|(provider, c)| {
150 let at = c.opened_at?;
151 let elapsed = now.saturating_sub(at);
152 let remaining = (policy.cooldown_secs as i64).saturating_sub(elapsed);
153 (remaining > 0).then(|| ProviderCircuitState {
154 provider: provider.clone(),
155 reason: c.reason,
156 consecutive_failures: c.consecutive_failures,
157 retry_in_secs: remaining as u64,
158 })
159 })
160 .collect();
161 open.sort_by(|a, b| a.provider.cmp(&b.provider));
162 open
163 }
164}
165
166pub fn rotate_open_circuits(
178 mut agents: Query<(Entity, &AgentState, &mut StageInference), With<super::ReadyToInfer>>,
179 circuits: Option<Res<ProviderCircuits>>,
180 policy: Option<Res<CircuitPolicy>>,
181) {
182 crate::tick_scope::clear();
183 let Some(circuits) = circuits else {
184 return; };
186 let policy = policy.map(|p| *p).unwrap_or_default();
187 let now = chrono::Utc::now().timestamp();
188 for (entity, state, mut si) in agents.iter_mut() {
189 crate::tick_scope::enter(entity);
190 if state.status != crate::components::AgentStatus::Active {
191 continue;
192 }
193 if !circuits.is_open(&si.provider_name, now, &policy) {
194 continue;
195 }
196 let Some(next) = si
199 .fallbacks
200 .iter()
201 .position(|e| !circuits.is_open(&e.provider, now, &policy))
202 else {
203 continue; };
205 let entry = si.fallbacks.remove(next);
206 si.fallbacks.drain(..next);
207 tracing::warn!(
208 from_provider = %si.provider_name,
209 to_provider = %entry.provider,
210 to_model = %entry.model,
211 "provider circuit is open; moving this run to the next candidate"
212 );
213 si.provider_name = entry.provider;
214 si.model = entry.model;
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 fn policy() -> CircuitPolicy {
223 CircuitPolicy {
224 failures_before_open: 3,
225 cooldown_secs: 300,
226 }
227 }
228
229 fn fail(circuits: &mut ProviderCircuits, now: i64) -> bool {
230 circuits.record_failure(
231 "openrouter",
232 UnavailableReason::CreditsExhausted,
233 now,
234 &policy(),
235 )
236 }
237
238 #[test]
239 fn the_circuit_opens_only_at_the_threshold() {
240 let mut circuits = ProviderCircuits::default();
241 assert!(!fail(&mut circuits, 0));
242 assert!(!circuits.is_open("openrouter", 0, &policy()));
243 assert!(!fail(&mut circuits, 1));
244 assert!(!circuits.is_open("openrouter", 1, &policy()));
245 assert!(fail(&mut circuits, 2), "the transition is reported");
247 assert!(circuits.is_open("openrouter", 2, &policy()));
248 assert!(
249 !fail(&mut circuits, 3),
250 "already open, not a new transition"
251 );
252 }
253
254 #[test]
255 fn an_untouched_provider_is_never_open() {
256 let circuits = ProviderCircuits::default();
257 assert!(!circuits.is_open("anthropic", 0, &policy()));
258 assert!(circuits.open_circuits(0, &policy()).is_empty());
259 }
260
261 #[test]
262 fn a_success_closes_the_circuit() {
263 let mut circuits = ProviderCircuits::default();
264 for t in 0..3 {
265 fail(&mut circuits, t);
266 }
267 assert!(circuits.is_open("openrouter", 2, &policy()));
268 circuits.record_success("openrouter");
269 assert!(!circuits.is_open("openrouter", 2, &policy()));
270 assert!(!fail(&mut circuits, 10));
272 assert!(!circuits.is_open("openrouter", 10, &policy()));
273 }
274
275 #[test]
276 fn the_cooldown_lets_a_probe_through() {
277 let mut circuits = ProviderCircuits::default();
278 for t in 0..3 {
279 fail(&mut circuits, t);
280 }
281 assert!(circuits.is_open("openrouter", 2 + 299, &policy()));
282 assert!(!circuits.is_open("openrouter", 2 + 300, &policy()));
284 }
285
286 #[test]
287 fn a_failed_probe_restarts_the_cooldown() {
288 let mut circuits = ProviderCircuits::default();
289 for t in 0..3 {
290 fail(&mut circuits, t);
291 }
292 assert!(
294 !fail(&mut circuits, 302),
295 "already open: not a new transition"
296 );
297 assert!(circuits.is_open("openrouter", 400, &policy()));
299 assert!(!circuits.is_open("openrouter", 602, &policy()));
300 }
301
302 #[test]
303 fn a_zero_threshold_disables_the_breaker() {
304 let disabled = CircuitPolicy {
305 failures_before_open: 0,
306 cooldown_secs: 300,
307 };
308 let mut circuits = ProviderCircuits::default();
309 for t in 0..10 {
310 assert!(!circuits.record_failure(
311 "openrouter",
312 UnavailableReason::CreditsExhausted,
313 t,
314 &disabled
315 ));
316 }
317 assert!(!circuits.is_open("openrouter", 10, &disabled));
318 assert!(circuits.open_circuits(10, &disabled).is_empty());
319 }
320
321 #[test]
322 fn open_circuits_reports_what_the_operator_needs() {
323 let mut circuits = ProviderCircuits::default();
324 for t in 0..3 {
325 fail(&mut circuits, t);
326 }
327 let open = circuits.open_circuits(102, &policy());
328 assert_eq!(open.len(), 1);
329 assert_eq!(open[0].provider, "openrouter");
330 assert_eq!(open[0].reason, UnavailableReason::CreditsExhausted);
331 assert_eq!(open[0].consecutive_failures, 3);
332 assert_eq!(open[0].retry_in_secs, 200);
334 }
335
336 #[test]
337 fn open_circuits_is_sorted_and_drops_expired_ones() {
338 let mut circuits = ProviderCircuits::default();
339 for name in ["openrouter", "anthropic"] {
340 for t in 0..3 {
341 circuits.record_failure(name, UnavailableReason::AuthFailed, t, &policy());
342 }
343 }
344 let open = circuits.open_circuits(10, &policy());
345 assert_eq!(
346 open.iter().map(|c| c.provider.as_str()).collect::<Vec<_>>(),
347 vec!["anthropic", "openrouter"],
348 "a HashMap's order is not stable; the report must be"
349 );
350 assert!(circuits.open_circuits(1_000, &policy()).is_empty());
352 }
353
354 #[test]
355 fn the_latest_reason_wins() {
356 let mut circuits = ProviderCircuits::default();
357 circuits.record_failure("p", UnavailableReason::CreditsExhausted, 0, &policy());
358 circuits.record_failure("p", UnavailableReason::AuthFailed, 1, &policy());
359 circuits.record_failure("p", UnavailableReason::AuthFailed, 2, &policy());
360 let open = circuits.open_circuits(2, &policy());
361 assert_eq!(open[0].reason, UnavailableReason::AuthFailed);
362 }
363
364 #[test]
365 fn the_default_policy_is_three_strikes_and_five_minutes() {
366 let p = CircuitPolicy::default();
367 assert_eq!(p.failures_before_open, DEFAULT_FAILURES_BEFORE_OPEN);
368 assert_eq!(p.cooldown_secs, DEFAULT_CIRCUIT_COOLDOWN_SECS);
369 }
370
371 fn agent_state() -> AgentState {
374 AgentState {
375 agent_id: "a".to_string(),
376 current_stage: "s".to_string(),
377 iteration: 0,
378 status: crate::components::AgentStatus::Active,
379 spawned_children_ids: vec![],
380 pending_wait: None,
381 accepts_messages: true,
382 }
383 }
384
385 fn stage_on(provider: &str, fallbacks: &[&str]) -> StageInference {
386 StageInference {
387 provider_name: provider.to_string(),
388 model: format!("{provider}-model"),
389 tools: Vec::new(),
390 tool_filter: None,
391 fallbacks: fallbacks
392 .iter()
393 .map(|p| {
394 leviath_core::blueprint::ModelEntry::new((*p).to_string(), format!("{p}-model"))
395 })
396 .collect(),
397 }
398 }
399
400 fn world_with_open(open: &[&str]) -> World {
402 let mut world = World::new();
403 let mut circuits = ProviderCircuits::default();
404 let now = chrono::Utc::now().timestamp();
405 for name in open {
406 for _ in 0..policy().failures_before_open {
407 circuits.record_failure(name, UnavailableReason::CreditsExhausted, now, &policy());
408 }
409 }
410 world.insert_resource(circuits);
411 world.insert_resource(policy());
412 world
413 }
414
415 fn run_rotate(world: &mut World) {
416 let mut schedule = Schedule::default();
417 schedule.add_systems(rotate_open_circuits);
418 schedule.run(world);
419 }
420
421 #[test]
422 fn rotation_moves_a_ready_agent_off_a_tripped_provider() {
423 let mut world = world_with_open(&["openrouter"]);
424 let e = world
425 .spawn((
426 agent_state(),
427 super::ReadyToInfer,
428 stage_on("openrouter", &["anthropic"]),
429 ))
430 .id();
431
432 run_rotate(&mut world);
433
434 let si = world.get::<StageInference>(e).unwrap();
435 assert_eq!(si.provider_name, "anthropic");
436 assert_eq!(si.model, "anthropic-model");
437 assert!(si.fallbacks.is_empty());
438 }
439
440 #[test]
441 fn rotation_skips_past_candidates_that_are_also_tripped() {
442 let mut world = world_with_open(&["openrouter", "openai"]);
443 let e = world
444 .spawn((
445 agent_state(),
446 super::ReadyToInfer,
447 stage_on("openrouter", &["openai", "anthropic"]),
448 ))
449 .id();
450
451 run_rotate(&mut world);
452
453 let si = world.get::<StageInference>(e).unwrap();
454 assert_eq!(si.provider_name, "anthropic");
455 assert!(si.fallbacks.is_empty());
458 }
459
460 #[test]
461 fn rotation_leaves_an_agent_with_nowhere_to_go_alone() {
462 let mut world = world_with_open(&["openrouter"]);
465 let e = world
466 .spawn((
467 agent_state(),
468 super::ReadyToInfer,
469 stage_on("openrouter", &[]),
470 ))
471 .id();
472
473 run_rotate(&mut world);
474
475 assert_eq!(
476 world.get::<StageInference>(e).unwrap().provider_name,
477 "openrouter"
478 );
479 }
480
481 #[test]
482 fn rotation_leaves_a_healthy_provider_alone() {
483 let mut world = world_with_open(&["openrouter"]);
484 let e = world
485 .spawn((
486 agent_state(),
487 super::ReadyToInfer,
488 stage_on("anthropic", &["openai"]),
489 ))
490 .id();
491
492 run_rotate(&mut world);
493
494 let si = world.get::<StageInference>(e).unwrap();
495 assert_eq!(si.provider_name, "anthropic");
496 assert_eq!(si.fallbacks.len(), 1, "no candidate was spent");
497 }
498
499 #[test]
500 fn rotation_ignores_an_agent_that_is_not_active() {
501 let mut world = world_with_open(&["openrouter"]);
503 let mut state = agent_state();
504 state.status = crate::components::AgentStatus::Paused;
505 let e = world
506 .spawn((
507 state,
508 super::ReadyToInfer,
509 stage_on("openrouter", &["anthropic"]),
510 ))
511 .id();
512
513 run_rotate(&mut world);
514
515 assert_eq!(
516 world.get::<StageInference>(e).unwrap().provider_name,
517 "openrouter"
518 );
519 }
520
521 #[test]
522 fn rotation_is_a_no_op_without_the_breaker_installed() {
523 let mut world = World::new();
525 let e = world
526 .spawn((
527 agent_state(),
528 super::ReadyToInfer,
529 stage_on("openrouter", &["anthropic"]),
530 ))
531 .id();
532
533 run_rotate(&mut world);
534
535 assert_eq!(
536 world.get::<StageInference>(e).unwrap().provider_name,
537 "openrouter"
538 );
539 }
540
541 #[test]
542 fn rotation_falls_back_to_the_default_policy() {
543 let mut world = World::new();
546 let mut circuits = ProviderCircuits::default();
547 let now = chrono::Utc::now().timestamp();
548 let default_policy = CircuitPolicy::default();
549 for _ in 0..default_policy.failures_before_open {
550 circuits.record_failure(
551 "openrouter",
552 UnavailableReason::CreditsExhausted,
553 now,
554 &default_policy,
555 );
556 }
557 world.insert_resource(circuits);
558 let e = world
559 .spawn((
560 agent_state(),
561 super::ReadyToInfer,
562 stage_on("openrouter", &["anthropic"]),
563 ))
564 .id();
565
566 run_rotate(&mut world);
567
568 assert_eq!(
569 world.get::<StageInference>(e).unwrap().provider_name,
570 "anthropic"
571 );
572 }
573}