diff --git a/lib/components/fabro-workflow/src/event/events.rs b/lib/components/fabro-workflow/src/event/events.rs index e03272779..126b23d90 100644 --- a/lib/components/fabro-workflow/src/event/events.rs +++ b/lib/components/fabro-workflow/src/event/events.rs @@ -1392,9 +1392,9 @@ impl Event { Self::Failover { stage, props } => { warn!( stage, - original_provider = %props.original_provider, - original_model = %props.original_model, - attempt = props.attempt, + original_provider = ?props.original_provider, + original_model = ?props.original_model, + attempt = ?props.attempt, from_provider = %props.from_provider, from_model = %props.from_model, to_provider = %props.to_provider, diff --git a/lib/components/fabro-workflow/src/handler/llm/api.rs b/lib/components/fabro-workflow/src/handler/llm/api.rs index 455810d99..5dad09d34 100644 --- a/lib/components/fabro-workflow/src/handler/llm/api.rs +++ b/lib/components/fabro-workflow/src/handler/llm/api.rs @@ -1352,9 +1352,9 @@ impl AgentApiBackend { &Event::Failover { stage: node.id.clone(), props: FailoverProps { - original_provider: plan.original.target.provider.to_string(), - original_model: plan.original.target.model.to_string(), - attempt: plan.attempt(), + original_provider: Some(plan.original.target.provider.to_string()), + original_model: Some(plan.original.target.model.to_string()), + attempt: Some(plan.attempt()), from_provider: from.target.provider.to_string(), from_model: from.target.model.to_string(), to_provider: to.target.provider.to_string(), @@ -3521,9 +3521,9 @@ enabled = true .unwrap() .clone() .expect("agent.failover should be emitted"); - assert_eq!(failover.original_provider, "anthropic"); - assert_eq!(failover.original_model, "claude-fable-5"); - assert_eq!(failover.attempt, 1); + assert_eq!(failover.original_provider.as_deref(), Some("anthropic")); + assert_eq!(failover.original_model.as_deref(), Some("claude-fable-5")); + assert_eq!(failover.attempt, Some(1)); assert_eq!(failover.from_provider, "anthropic"); assert_eq!(failover.from_model, "claude-fable-5"); assert_eq!(failover.to_provider, "openai"); diff --git a/lib/foundation/fabro-types/src/run_event/misc.rs b/lib/foundation/fabro-types/src/run_event/misc.rs index fb0df5096..e1505c6d3 100644 --- a/lib/foundation/fabro-types/src/run_event/misc.rs +++ b/lib/foundation/fabro-types/src/run_event/misc.rs @@ -188,9 +188,15 @@ pub struct SshAccessReadyProps { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct FailoverProps { - pub original_provider: String, - pub original_model: String, - pub attempt: u32, + /// `original_*` and `attempt` are `Option` only because failover events + /// recorded before model-keyed fallbacks lack them. New events always set + /// them; stored events are immutable, so absence stays a supported input. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub original_provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub original_model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attempt: Option, pub from_provider: String, pub from_model: String, pub to_provider: String, diff --git a/lib/foundation/fabro-types/src/run_event/mod.rs b/lib/foundation/fabro-types/src/run_event/mod.rs index 6680fff2e..27c3b87e9 100644 --- a/lib/foundation/fabro-types/src/run_event/mod.rs +++ b/lib/foundation/fabro-types/src/run_event/mod.rs @@ -1013,6 +1013,33 @@ mod tests { assert!(matches!(parsed.body, EventBody::RunCreated(_))); } + #[test] + fn historical_failover_event_defaults_new_route_context() { + let line = json!({ + "id": "evt_failover", + "ts": "2026-04-04T12:00:00.000Z", + "run_id": fixtures::RUN_1, + "event": "agent.failover", + "properties": { + "from_provider": "anthropic", + "from_model": "claude-fable-5", + "to_provider": "openai", + "to_model": "gpt-5.6-sol", + "error": "provider unavailable" + } + }); + + let parsed = RunEvent::from_value(line).unwrap(); + let EventBody::Failover(props) = parsed.body else { + panic!("expected agent.failover"); + }; + assert_eq!(props.original_provider, None); + assert_eq!(props.original_model, None); + assert_eq!(props.attempt, None); + assert_eq!(props.requested_reasoning_effort, None); + assert_eq!(props.effective_reasoning_effort, None); + } + #[test] fn run_created_round_trip_preserves_manifest_blob() { let line = json!({ diff --git a/lib/foundation/fabro-types/src/settings/run.rs b/lib/foundation/fabro-types/src/settings/run.rs index 5ecebdd0c..ace7cb3ab 100644 --- a/lib/foundation/fabro-types/src/settings/run.rs +++ b/lib/foundation/fabro-types/src/settings/run.rs @@ -680,7 +680,7 @@ pub enum RunGoal { File(InterpString), } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize)] pub struct RunModelSettings { pub provider: Option, pub name: Option, @@ -697,6 +697,94 @@ pub struct RunModelSettings { pub controls: RunModelControls, } +/// Temporary compatibility deserializer: releases before model-keyed +/// fallbacks serialized `fallbacks` as a flat array that applied to the run's +/// requested model, and that shape persists inside stored `run.created` +/// events. A legacy array is keyed under `name` when one is set; a legacy +/// chain for the implicit default model cannot be keyed and is dropped. +/// +/// Remove once run logs recorded by pre-model-keyed releases (< 0.311) are +/// out of the support window; then restore `derive(Deserialize)`. +impl<'de> serde::Deserialize<'de> for RunModelSettings { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Shadow { + provider: Option, + name: Option, + fallbacks: FallbacksCompat, + #[serde(default)] + controls: RunModelControls, + } + + #[derive(Deserialize)] + #[serde(untagged)] + enum FallbacksCompat { + Keyed(BTreeMap>), + Legacy(Vec), + } + + let shadow = Shadow::deserialize(deserializer)?; + let fallbacks = match shadow.fallbacks { + FallbacksCompat::Keyed(chains) => chains, + FallbacksCompat::Legacy(chain) => match (&shadow.name, chain) { + (Some(name), chain) if !chain.is_empty() => BTreeMap::from([(name.clone(), chain)]), + _ => BTreeMap::new(), + }, + }; + Ok(Self { + provider: shadow.provider, + name: shadow.name, + fallbacks, + controls: shadow.controls, + }) + } +} + +#[cfg(test)] +mod run_model_settings_compat_tests { + use super::RunModelSettings; + + #[test] + fn keyed_fallbacks_round_trip() { + let settings: RunModelSettings = serde_json::from_value(serde_json::json!({ + "provider": "openrouter", + "name": "claude-fable", + "fallbacks": {"claude-fable": ["gpt-sol"]} + })) + .unwrap(); + let chain = &settings.fallbacks["claude-fable"]; + assert_eq!(chain.len(), 1); + assert_eq!(chain[0].to_string(), "gpt-sol"); + } + + #[test] + fn legacy_array_fallbacks_key_under_the_requested_model() { + let settings: RunModelSettings = serde_json::from_value(serde_json::json!({ + "provider": null, + "name": "claude-fable", + "fallbacks": ["gpt-sol", "openrouter:claude-opus"] + })) + .unwrap(); + let chain = &settings.fallbacks["claude-fable"]; + assert_eq!(chain.len(), 2); + assert_eq!(chain[1].to_string(), "openrouter:claude-opus"); + } + + #[test] + fn legacy_array_without_a_requested_model_is_dropped() { + let settings: RunModelSettings = serde_json::from_value(serde_json::json!({ + "provider": null, + "name": null, + "fallbacks": ["gpt-sol"] + })) + .unwrap(); + assert!(settings.fallbacks.is_empty()); + } +} + #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct RunModelControls { pub reasoning_effort: Option,