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 output: None,
398 }
399 }
400
401 fn world_with_open(open: &[&str]) -> World {
403 let mut world = World::new();
404 let mut circuits = ProviderCircuits::default();
405 let now = chrono::Utc::now().timestamp();
406 for name in open {
407 for _ in 0..policy().failures_before_open {
408 circuits.record_failure(name, UnavailableReason::CreditsExhausted, now, &policy());
409 }
410 }
411 world.insert_resource(circuits);
412 world.insert_resource(policy());
413 world
414 }
415
416 fn run_rotate(world: &mut World) {
417 let mut schedule = Schedule::default();
418 schedule.add_systems(rotate_open_circuits);
419 schedule.run(world);
420 }
421
422 #[test]
423 fn rotation_moves_a_ready_agent_off_a_tripped_provider() {
424 let mut world = world_with_open(&["openrouter"]);
425 let e = world
426 .spawn((
427 agent_state(),
428 super::ReadyToInfer,
429 stage_on("openrouter", &["anthropic"]),
430 ))
431 .id();
432
433 run_rotate(&mut world);
434
435 let si = world.get::<StageInference>(e).unwrap();
436 assert_eq!(si.provider_name, "anthropic");
437 assert_eq!(si.model, "anthropic-model");
438 assert!(si.fallbacks.is_empty());
439 }
440
441 #[test]
442 fn rotation_skips_past_candidates_that_are_also_tripped() {
443 let mut world = world_with_open(&["openrouter", "openai"]);
444 let e = world
445 .spawn((
446 agent_state(),
447 super::ReadyToInfer,
448 stage_on("openrouter", &["openai", "anthropic"]),
449 ))
450 .id();
451
452 run_rotate(&mut world);
453
454 let si = world.get::<StageInference>(e).unwrap();
455 assert_eq!(si.provider_name, "anthropic");
456 assert!(si.fallbacks.is_empty());
459 }
460
461 #[test]
462 fn rotation_leaves_an_agent_with_nowhere_to_go_alone() {
463 let mut world = world_with_open(&["openrouter"]);
466 let e = world
467 .spawn((
468 agent_state(),
469 super::ReadyToInfer,
470 stage_on("openrouter", &[]),
471 ))
472 .id();
473
474 run_rotate(&mut world);
475
476 assert_eq!(
477 world.get::<StageInference>(e).unwrap().provider_name,
478 "openrouter"
479 );
480 }
481
482 #[test]
483 fn rotation_leaves_a_healthy_provider_alone() {
484 let mut world = world_with_open(&["openrouter"]);
485 let e = world
486 .spawn((
487 agent_state(),
488 super::ReadyToInfer,
489 stage_on("anthropic", &["openai"]),
490 ))
491 .id();
492
493 run_rotate(&mut world);
494
495 let si = world.get::<StageInference>(e).unwrap();
496 assert_eq!(si.provider_name, "anthropic");
497 assert_eq!(si.fallbacks.len(), 1, "no candidate was spent");
498 }
499
500 #[test]
501 fn rotation_ignores_an_agent_that_is_not_active() {
502 let mut world = world_with_open(&["openrouter"]);
504 let mut state = agent_state();
505 state.status = crate::components::AgentStatus::Paused;
506 let e = world
507 .spawn((
508 state,
509 super::ReadyToInfer,
510 stage_on("openrouter", &["anthropic"]),
511 ))
512 .id();
513
514 run_rotate(&mut world);
515
516 assert_eq!(
517 world.get::<StageInference>(e).unwrap().provider_name,
518 "openrouter"
519 );
520 }
521
522 #[test]
523 fn rotation_is_a_no_op_without_the_breaker_installed() {
524 let mut world = World::new();
526 let e = world
527 .spawn((
528 agent_state(),
529 super::ReadyToInfer,
530 stage_on("openrouter", &["anthropic"]),
531 ))
532 .id();
533
534 run_rotate(&mut world);
535
536 assert_eq!(
537 world.get::<StageInference>(e).unwrap().provider_name,
538 "openrouter"
539 );
540 }
541
542 #[test]
543 fn rotation_falls_back_to_the_default_policy() {
544 let mut world = World::new();
547 let mut circuits = ProviderCircuits::default();
548 let now = chrono::Utc::now().timestamp();
549 let default_policy = CircuitPolicy::default();
550 for _ in 0..default_policy.failures_before_open {
551 circuits.record_failure(
552 "openrouter",
553 UnavailableReason::CreditsExhausted,
554 now,
555 &default_policy,
556 );
557 }
558 world.insert_resource(circuits);
559 let e = world
560 .spawn((
561 agent_state(),
562 super::ReadyToInfer,
563 stage_on("openrouter", &["anthropic"]),
564 ))
565 .id();
566
567 run_rotate(&mut world);
568
569 assert_eq!(
570 world.get::<StageInference>(e).unwrap().provider_name,
571 "anthropic"
572 );
573 }
574}