Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions crates/libsy-llm-client/tests/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1436,3 +1436,39 @@ async fn classifier_fail_open_records_each_failure_stage() -> switchyard_libsy::
}
Ok(())
}

/// A decision made by the cascade fallback still names its routing tier.
///
/// The judge call fails, so `TaskClassifier` fails open and `DefaultTarget`
/// decides. That decider defines no tier of its own, so without the cascade
/// lookup the selection logs `tier: None` while a judged decision to the same
/// target logs its tier. `AffinityRouter` reuse takes the same path.
#[tokio::test]
async fn fallback_decision_logs_the_routing_tier() -> switchyard_libsy::Result<()> {
let _guard = serialize_test().lock().await;
let (store, _, _, _, _) = telemetry();

let client = Arc::new(JudgeClient {
judge_model: "ft-judge".into(),
outcome: JudgeOutcome::CallFailure,
}) as Arc<dyn RoutedLlmClient>;
run(
classifier_router("ft-judge", "ft-weak", "ft-strong")?,
client,
classifier_request(),
)
.await?;

let events = store.events();
assert!(
events.iter().any(|event| {
event.fields.get("target").map(String::as_str) == Some("ft-strong")
&& event
.fields
.get("tier")
.is_some_and(|tier| tier.contains("strong"))
}),
"fallback selection logged without its routing tier: {events:?}"
);
Ok(())
}
8 changes: 7 additions & 1 deletion crates/libsy/src/algorithms/fall_through.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,13 @@ where
// 3. Resolve the target and log the choice.
algorithm::ensure_model_is_target(&self.targets, &score.target)?;
let target = score.target.clone();
let tier = deciding.routing_tier(&target);
// A fallback or affinity-reuse decider carries no tier of its own, so
// resolve it across the cascade rather than logging it as None.
let tier = deciding.routing_tier(&target).or_else(|| {
self.classifiers
.iter()
.find_map(|c| c.routing_tier(&target))
});
tracing::info!(algorithm=self.name, target=%score.target, confidence=score.confidence, tier = ?tier, "Model selected");

// 4. Post-decision replay: every processor sees the selection so stateful ones
Expand Down
Loading