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 last_reason(&self, provider: &str) -> Option<UnavailableReason> {
138 self.0.get(provider).map(|c| c.reason)
139 }
140
141 pub fn reset(&mut self) {
147 self.0.clear();
148 }
149
150 pub fn is_open(&self, provider: &str, now: i64, policy: &CircuitPolicy) -> bool {
155 self.0
156 .get(provider)
157 .and_then(|c| c.opened_at)
158 .is_some_and(|at| now.saturating_sub(at) < policy.cooldown_secs as i64)
159 }
160
161 pub fn open_circuits(&self, now: i64, policy: &CircuitPolicy) -> Vec<ProviderCircuitState> {
164 let mut open: Vec<ProviderCircuitState> = self
165 .0
166 .iter()
167 .filter_map(|(provider, c)| {
168 let at = c.opened_at?;
169 let elapsed = now.saturating_sub(at);
170 let remaining = (policy.cooldown_secs as i64).saturating_sub(elapsed);
171 (remaining > 0).then(|| ProviderCircuitState {
172 provider: provider.clone(),
173 reason: c.reason,
174 consecutive_failures: c.consecutive_failures,
175 retry_in_secs: remaining as u64,
176 })
177 })
178 .collect();
179 open.sort_by(|a, b| a.provider.cmp(&b.provider));
180 open
181 }
182}
183
184pub fn rotate_open_circuits(
196 mut agents: Query<(Entity, &AgentState, &mut StageInference), With<super::ReadyToInfer>>,
197 circuits: Option<Res<ProviderCircuits>>,
198 policy: Option<Res<CircuitPolicy>>,
199) {
200 crate::tick_scope::clear();
201 let Some(circuits) = circuits else {
202 return; };
204 let policy = policy.map(|p| *p).unwrap_or_default();
205 let now = chrono::Utc::now().timestamp();
206 for (entity, state, mut si) in agents.iter_mut() {
207 crate::tick_scope::enter(entity);
208 if state.status != crate::components::AgentStatus::Active {
209 continue;
210 }
211 if !circuits.is_open(&si.provider_name, now, &policy) {
212 continue;
213 }
214 let Some(next) = si
217 .fallbacks
218 .iter()
219 .position(|e| !circuits.is_open(&e.provider, now, &policy))
220 else {
221 continue; };
223 let entry = si.fallbacks.remove(next);
224 si.fallbacks.drain(..next);
225 tracing::warn!(
226 from_provider = %si.provider_name,
227 to_provider = %entry.provider,
228 to_model = %entry.model,
229 "provider circuit is open; moving this run to the next candidate"
230 );
231 si.provider_name = entry.provider;
232 si.model = entry.model;
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 fn policy() -> CircuitPolicy {
241 CircuitPolicy {
242 failures_before_open: 3,
243 cooldown_secs: 300,
244 }
245 }
246
247 fn fail(circuits: &mut ProviderCircuits, now: i64) -> bool {
248 circuits.record_failure(
249 "openrouter",
250 UnavailableReason::CreditsExhausted,
251 now,
252 &policy(),
253 )
254 }
255
256 #[test]
257 fn the_circuit_opens_only_at_the_threshold() {
258 let mut circuits = ProviderCircuits::default();
259 assert!(!fail(&mut circuits, 0));
260 assert!(!circuits.is_open("openrouter", 0, &policy()));
261 assert!(!fail(&mut circuits, 1));
262 assert!(!circuits.is_open("openrouter", 1, &policy()));
263 assert!(fail(&mut circuits, 2), "the transition is reported");
265 assert!(circuits.is_open("openrouter", 2, &policy()));
266 assert!(
267 !fail(&mut circuits, 3),
268 "already open, not a new transition"
269 );
270 }
271
272 #[test]
273 fn an_untouched_provider_is_never_open() {
274 let circuits = ProviderCircuits::default();
275 assert!(!circuits.is_open("anthropic", 0, &policy()));
276 assert!(circuits.open_circuits(0, &policy()).is_empty());
277 }
278
279 #[test]
280 fn a_success_closes_the_circuit() {
281 let mut circuits = ProviderCircuits::default();
282 for t in 0..3 {
283 fail(&mut circuits, t);
284 }
285 assert!(circuits.is_open("openrouter", 2, &policy()));
286 circuits.record_success("openrouter");
287 assert!(!circuits.is_open("openrouter", 2, &policy()));
288 assert!(!fail(&mut circuits, 10));
290 assert!(!circuits.is_open("openrouter", 10, &policy()));
291 }
292
293 #[test]
294 fn last_reason_reports_the_most_recent_failure_or_nothing() {
295 let mut circuits = ProviderCircuits::default();
296 assert_eq!(circuits.last_reason("p"), None);
297 circuits.record_failure("p", UnavailableReason::CreditsExhausted, 0, &policy());
298 assert_eq!(
299 circuits.last_reason("p"),
300 Some(UnavailableReason::CreditsExhausted),
301 "one failure is enough for the reason, open or not"
302 );
303 }
304
305 #[test]
306 fn reset_forgets_every_circuit() {
307 let mut circuits = ProviderCircuits::default();
310 let mut now = 0;
311 while !fail(&mut circuits, now) {
312 now += 1;
313 }
314 assert!(circuits.is_open("openrouter", now, &policy()));
315 circuits.reset();
316 assert!(!circuits.is_open("openrouter", now, &policy()));
317 assert_eq!(circuits.last_reason("openrouter"), None);
318 }
319
320 #[test]
321 fn the_cooldown_lets_a_probe_through() {
322 let mut circuits = ProviderCircuits::default();
323 for t in 0..3 {
324 fail(&mut circuits, t);
325 }
326 assert!(circuits.is_open("openrouter", 2 + 299, &policy()));
327 assert!(!circuits.is_open("openrouter", 2 + 300, &policy()));
329 }
330
331 #[test]
332 fn a_failed_probe_restarts_the_cooldown() {
333 let mut circuits = ProviderCircuits::default();
334 for t in 0..3 {
335 fail(&mut circuits, t);
336 }
337 assert!(
339 !fail(&mut circuits, 302),
340 "already open: not a new transition"
341 );
342 assert!(circuits.is_open("openrouter", 400, &policy()));
344 assert!(!circuits.is_open("openrouter", 602, &policy()));
345 }
346
347 #[test]
348 fn a_zero_threshold_disables_the_breaker() {
349 let disabled = CircuitPolicy {
350 failures_before_open: 0,
351 cooldown_secs: 300,
352 };
353 let mut circuits = ProviderCircuits::default();
354 for t in 0..10 {
355 assert!(!circuits.record_failure(
356 "openrouter",
357 UnavailableReason::CreditsExhausted,
358 t,
359 &disabled
360 ));
361 }
362 assert!(!circuits.is_open("openrouter", 10, &disabled));
363 assert!(circuits.open_circuits(10, &disabled).is_empty());
364 }
365
366 #[test]
367 fn open_circuits_reports_what_the_operator_needs() {
368 let mut circuits = ProviderCircuits::default();
369 for t in 0..3 {
370 fail(&mut circuits, t);
371 }
372 let open = circuits.open_circuits(102, &policy());
373 assert_eq!(open.len(), 1);
374 assert_eq!(open[0].provider, "openrouter");
375 assert_eq!(open[0].reason, UnavailableReason::CreditsExhausted);
376 assert_eq!(open[0].consecutive_failures, 3);
377 assert_eq!(open[0].retry_in_secs, 200);
379 }
380
381 #[test]
382 fn open_circuits_is_sorted_and_drops_expired_ones() {
383 let mut circuits = ProviderCircuits::default();
384 for name in ["openrouter", "anthropic"] {
385 for t in 0..3 {
386 circuits.record_failure(name, UnavailableReason::AuthFailed, t, &policy());
387 }
388 }
389 let open = circuits.open_circuits(10, &policy());
390 assert_eq!(
391 open.iter().map(|c| c.provider.as_str()).collect::<Vec<_>>(),
392 vec!["anthropic", "openrouter"],
393 "a HashMap's order is not stable; the report must be"
394 );
395 assert!(circuits.open_circuits(1_000, &policy()).is_empty());
397 }
398
399 #[test]
400 fn the_latest_reason_wins() {
401 let mut circuits = ProviderCircuits::default();
402 circuits.record_failure("p", UnavailableReason::CreditsExhausted, 0, &policy());
403 circuits.record_failure("p", UnavailableReason::AuthFailed, 1, &policy());
404 circuits.record_failure("p", UnavailableReason::AuthFailed, 2, &policy());
405 let open = circuits.open_circuits(2, &policy());
406 assert_eq!(open[0].reason, UnavailableReason::AuthFailed);
407 }
408
409 #[test]
410 fn the_default_policy_is_three_strikes_and_five_minutes() {
411 let p = CircuitPolicy::default();
412 assert_eq!(p.failures_before_open, DEFAULT_FAILURES_BEFORE_OPEN);
413 assert_eq!(p.cooldown_secs, DEFAULT_CIRCUIT_COOLDOWN_SECS);
414 }
415
416 fn agent_state() -> AgentState {
419 AgentState {
420 agent_id: "a".to_string(),
421 current_stage: "s".to_string(),
422 iteration: 0,
423 status: crate::components::AgentStatus::Active,
424 spawned_children_ids: vec![],
425 pending_wait: None,
426 accepts_messages: true,
427 }
428 }
429
430 fn stage_on(provider: &str, fallbacks: &[&str]) -> StageInference {
431 StageInference {
432 provider_name: provider.to_string(),
433 model: format!("{provider}-model"),
434 tools: Vec::new(),
435 tool_filter: None,
436 fallbacks: fallbacks
437 .iter()
438 .map(|p| {
439 leviath_core::blueprint::ModelEntry::new((*p).to_string(), format!("{p}-model"))
440 })
441 .collect(),
442 output: None,
443 }
444 }
445
446 fn world_with_open(open: &[&str]) -> World {
448 let mut world = World::new();
449 let mut circuits = ProviderCircuits::default();
450 let now = chrono::Utc::now().timestamp();
451 for name in open {
452 for _ in 0..policy().failures_before_open {
453 circuits.record_failure(name, UnavailableReason::CreditsExhausted, now, &policy());
454 }
455 }
456 world.insert_resource(circuits);
457 world.insert_resource(policy());
458 world
459 }
460
461 fn run_rotate(world: &mut World) {
462 let mut schedule = Schedule::default();
463 schedule.add_systems(rotate_open_circuits);
464 schedule.run(world);
465 }
466
467 #[test]
468 fn rotation_moves_a_ready_agent_off_a_tripped_provider() {
469 let mut world = world_with_open(&["openrouter"]);
470 let e = world
471 .spawn((
472 agent_state(),
473 super::ReadyToInfer,
474 stage_on("openrouter", &["anthropic"]),
475 ))
476 .id();
477
478 run_rotate(&mut world);
479
480 let si = world.get::<StageInference>(e).unwrap();
481 assert_eq!(si.provider_name, "anthropic");
482 assert_eq!(si.model, "anthropic-model");
483 assert!(si.fallbacks.is_empty());
484 }
485
486 #[test]
487 fn rotation_skips_past_candidates_that_are_also_tripped() {
488 let mut world = world_with_open(&["openrouter", "openai"]);
489 let e = world
490 .spawn((
491 agent_state(),
492 super::ReadyToInfer,
493 stage_on("openrouter", &["openai", "anthropic"]),
494 ))
495 .id();
496
497 run_rotate(&mut world);
498
499 let si = world.get::<StageInference>(e).unwrap();
500 assert_eq!(si.provider_name, "anthropic");
501 assert!(si.fallbacks.is_empty());
504 }
505
506 #[test]
507 fn rotation_leaves_an_agent_with_nowhere_to_go_alone() {
508 let mut world = world_with_open(&["openrouter"]);
511 let e = world
512 .spawn((
513 agent_state(),
514 super::ReadyToInfer,
515 stage_on("openrouter", &[]),
516 ))
517 .id();
518
519 run_rotate(&mut world);
520
521 assert_eq!(
522 world.get::<StageInference>(e).unwrap().provider_name,
523 "openrouter"
524 );
525 }
526
527 #[test]
528 fn rotation_leaves_a_healthy_provider_alone() {
529 let mut world = world_with_open(&["openrouter"]);
530 let e = world
531 .spawn((
532 agent_state(),
533 super::ReadyToInfer,
534 stage_on("anthropic", &["openai"]),
535 ))
536 .id();
537
538 run_rotate(&mut world);
539
540 let si = world.get::<StageInference>(e).unwrap();
541 assert_eq!(si.provider_name, "anthropic");
542 assert_eq!(si.fallbacks.len(), 1, "no candidate was spent");
543 }
544
545 #[test]
546 fn rotation_ignores_an_agent_that_is_not_active() {
547 let mut world = world_with_open(&["openrouter"]);
549 let mut state = agent_state();
550 state.status = crate::components::AgentStatus::Paused;
551 let e = world
552 .spawn((
553 state,
554 super::ReadyToInfer,
555 stage_on("openrouter", &["anthropic"]),
556 ))
557 .id();
558
559 run_rotate(&mut world);
560
561 assert_eq!(
562 world.get::<StageInference>(e).unwrap().provider_name,
563 "openrouter"
564 );
565 }
566
567 #[test]
568 fn rotation_is_a_no_op_without_the_breaker_installed() {
569 let mut world = World::new();
571 let e = world
572 .spawn((
573 agent_state(),
574 super::ReadyToInfer,
575 stage_on("openrouter", &["anthropic"]),
576 ))
577 .id();
578
579 run_rotate(&mut world);
580
581 assert_eq!(
582 world.get::<StageInference>(e).unwrap().provider_name,
583 "openrouter"
584 );
585 }
586
587 #[test]
588 fn rotation_falls_back_to_the_default_policy() {
589 let mut world = World::new();
592 let mut circuits = ProviderCircuits::default();
593 let now = chrono::Utc::now().timestamp();
594 let default_policy = CircuitPolicy::default();
595 for _ in 0..default_policy.failures_before_open {
596 circuits.record_failure(
597 "openrouter",
598 UnavailableReason::CreditsExhausted,
599 now,
600 &default_policy,
601 );
602 }
603 world.insert_resource(circuits);
604 let e = world
605 .spawn((
606 agent_state(),
607 super::ReadyToInfer,
608 stage_on("openrouter", &["anthropic"]),
609 ))
610 .id();
611
612 run_rotate(&mut world);
613
614 assert_eq!(
615 world.get::<StageInference>(e).unwrap().provider_name,
616 "anthropic"
617 );
618 }
619}