fix: keep stored run events from older releases deserializable

Copilot review flagged two backward-compatibility breaks with events
persisted by pre-model-keyed releases; both are stored data that can
never be rewritten, so accept the old shapes on read:

- FailoverProps: original_provider/original_model/attempt are Option
  again with serde defaults. New events always set them; failover events
  recorded before model-keyed fallbacks lack them. Restores the
  historical-event test.
- RunModelSettings: temporary custom deserializer accepts the legacy
  flat-array fallbacks shape inside stored run.created events, keying
  the chain under the requested model name when one is set. Remove once
  pre-0.311 run logs are out of the support window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-07-30 13:24:08 -04:00
parent 84c1545812
commit d4119cee77
No known key found for this signature in database
5 changed files with 134 additions and 13 deletions

View file

@ -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,

View file

@ -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");

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub original_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attempt: Option<u32>,
pub from_provider: String,
pub from_model: String,
pub to_provider: String,

View file

@ -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!({

View file

@ -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<String>,
pub name: Option<String>,
@ -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<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Shadow {
provider: Option<String>,
name: Option<String>,
fallbacks: FallbacksCompat,
#[serde(default)]
controls: RunModelControls,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum FallbacksCompat {
Keyed(BTreeMap<String, Vec<ModelRef>>),
Legacy(Vec<ModelRef>),
}
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<String>,