mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
84c1545812
commit
d4119cee77
5 changed files with 134 additions and 13 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue