Skip to content

Commit ffca4e8

Browse files
feat(libsy): count classifier fail-open fallbacks on /metrics (#205)
Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
1 parent eea4e01 commit ffca4e8

5 files changed

Lines changed: 268 additions & 41 deletions

File tree

crates/libsy/src/algorithms/util/llm_judge.rs

Lines changed: 91 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use crate::core::algorithm::{Driver, LlmTarget};
2222
use crate::core::classifier::{Classification, Classifier};
2323
use crate::core::state::State;
2424
use crate::{LibsyError, Result};
25-
use switchyard_protocol::{Context, Decision, Request, Response};
25+
use switchyard_protocol::{Context, Decision, LlmClientError, Request, Response};
2626

2727
/// Builds the classifier-specific message view presented to a structured judge.
2828
pub(crate) trait ClassifierInput: Send + Sync {
@@ -215,7 +215,7 @@ where
215215
/// A judge is an optimization, not a dependency: failing the caller's request because the
216216
/// judge is down would be worse than routing without it, so every failure — transport,
217217
/// mid-stream, or unparseable reply — is logged and folded into `None` for the policy's
218-
/// fail-closed branch. A closed driver stream is folded too; the algorithm's next driver
218+
/// fallback branch. A closed driver stream is folded too; the algorithm's next driver
219219
/// call surfaces it, so nothing is masked.
220220
async fn verdict(
221221
&self,
@@ -224,14 +224,6 @@ where
224224
driver: &Driver,
225225
) -> Option<J::Verdict> {
226226
let judge_model = self.target.semantic_name.as_str();
227-
let warn = |error: &dyn std::fmt::Display| {
228-
tracing::warn!(
229-
target: "libsy",
230-
judge_model,
231-
error = %error,
232-
"judge verdict unavailable; routing without one"
233-
);
234-
};
235227

236228
let response = driver
237229
.call_llm_target(
@@ -243,21 +235,57 @@ where
243235
}),
244236
)
245237
.await
246-
.inspect_err(|error| warn(error))
238+
.inspect_err(|error| report_fail_open(judge_model, error, libsy_error_reason(error)))
247239
.ok()?;
248240
let aggregate = response
249241
.llm_response
250242
.into_agg()
251243
.await
252-
.inspect_err(|error| warn(error))
244+
.inspect_err(|error| report_fail_open(judge_model, error, client_error_reason(error)))
253245
.ok()?;
254246
self.judge
255247
.parse(&aggregate)
256-
.inspect_err(|error| warn(error))
248+
.inspect_err(|error| report_fail_open(judge_model, error, "parse_error"))
257249
.ok()
258250
}
259251
}
260252

253+
/// Logs and counts a judge failure with a bounded label that excludes message content.
254+
fn report_fail_open(judge_model: &str, error: &dyn std::fmt::Display, reason: &'static str) {
255+
tracing::warn!(
256+
target: "libsy",
257+
judge_model,
258+
reason,
259+
error = %error,
260+
"judge verdict unavailable; routing without one"
261+
);
262+
crate::observability::record_classifier_fail_open(judge_model, reason);
263+
}
264+
265+
/// Returns a bounded reason for a judge call that failed at the libsy layer.
266+
fn libsy_error_reason(error: &LibsyError) -> &'static str {
267+
match error {
268+
LibsyError::ClientCall { source, .. } => client_error_reason(source),
269+
_ => "call_error",
270+
}
271+
}
272+
273+
/// Returns a bounded reason from the error kind and HTTP status only.
274+
fn client_error_reason(error: &LlmClientError) -> &'static str {
275+
match error {
276+
LlmClientError::Timeout { .. } => "timeout",
277+
LlmClientError::Transport { .. } => "transport",
278+
LlmClientError::UpstreamHttp { status, .. } if (500..=599).contains(status) => {
279+
"upstream_5xx"
280+
}
281+
LlmClientError::UpstreamHttp { .. } => "upstream_non_5xx",
282+
LlmClientError::InvalidResponse { .. } | LlmClientError::ResponseTranslation(_) => {
283+
"invalid_response"
284+
}
285+
_ => "client_error",
286+
}
287+
}
288+
261289
#[async_trait]
262290
impl<J, P> Classifier<State> for JudgeClassifier<J, P>
263291
where
@@ -544,6 +572,56 @@ mod tests {
544572
Ok(())
545573
}
546574

575+
#[test]
576+
fn client_errors_map_to_bounded_fail_open_reasons() {
577+
let cases = vec![
578+
(
579+
LlmClientError::Timeout {
580+
source: "deadline exceeded".into(),
581+
},
582+
"timeout",
583+
),
584+
(
585+
LlmClientError::Transport {
586+
source: "connection refused".into(),
587+
},
588+
"transport",
589+
),
590+
(
591+
LlmClientError::UpstreamHttp {
592+
status: 500,
593+
body: "server error".to_string(),
594+
},
595+
"upstream_5xx",
596+
),
597+
(
598+
LlmClientError::UpstreamHttp {
599+
status: 302,
600+
body: "redirect".to_string(),
601+
},
602+
"upstream_non_5xx",
603+
),
604+
(
605+
LlmClientError::InvalidResponse {
606+
source: "invalid JSON".into(),
607+
},
608+
"invalid_response",
609+
),
610+
(
611+
LlmClientError::General("unexpected client failure".to_string()),
612+
"client_error",
613+
),
614+
];
615+
for (error, expected) in cases {
616+
assert_eq!(client_error_reason(&error), expected);
617+
}
618+
619+
let error = LibsyError::AlgorithmError {
620+
message: "driver failed".to_string(),
621+
};
622+
assert_eq!(libsy_error_reason(&error), "call_error");
623+
}
624+
547625
#[tokio::test]
548626
async fn a_missing_driver_is_an_error_not_a_fallback() -> Result<()> {
549627
let mut request = request();

crates/libsy/src/observability.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,20 @@ fn record_routing_overhead(
513513
Some(overhead)
514514
}
515515

516+
/// Records a judge failure that made the classifier route without a verdict.
517+
pub(crate) fn record_classifier_fail_open(judge_model: &str, reason: &'static str) {
518+
meter()
519+
.u64_counter("switchyard.classifier_fail_open")
520+
.build()
521+
.add(
522+
1,
523+
&[
524+
KeyValue::new("judge_model", judge_model.to_string()),
525+
KeyValue::new("reason", reason),
526+
],
527+
);
528+
}
529+
516530
/// Records the resolution of one offloaded model call: the call counter and
517531
/// latency histogram, the `outcome`/`error`/token fields on `span`, and a warn
518532
/// log when the call failed.

crates/libsy/tests/observability.rs

Lines changed: 145 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,58 @@ impl RoutedLlmClient for ClassifierClient {
369369
}
370370
}
371371

372+
enum JudgeOutcome {
373+
CallFailure,
374+
Reply(&'static str),
375+
StreamDecodeFailure,
376+
}
377+
378+
/// Returns one configured judge outcome and serves the selected target normally.
379+
struct JudgeClient {
380+
outcome: JudgeOutcome,
381+
}
382+
383+
#[async_trait]
384+
impl RoutedLlmClient for JudgeClient {
385+
async fn call(
386+
&self,
387+
_ctx: Context,
388+
_request: Request,
389+
decision: Arc<dyn Decision>,
390+
) -> Result<Response, LlmClientError> {
391+
if decision.is_routed_call() {
392+
return Ok(Response {
393+
llm_response: LlmResponse::Agg(text_response(
394+
Some(decision.selected_model().to_string()),
395+
"routed response",
396+
)),
397+
metadata: None,
398+
});
399+
}
400+
match &self.outcome {
401+
JudgeOutcome::CallFailure => Err(LlmClientError::UpstreamHttp {
402+
status: 500,
403+
body: "server error".to_string(),
404+
}),
405+
JudgeOutcome::Reply(text) => Ok(Response {
406+
llm_response: LlmResponse::Agg(text_response(None, *text)),
407+
metadata: None,
408+
}),
409+
JudgeOutcome::StreamDecodeFailure => Ok(Response {
410+
llm_response: LlmResponse::Stream(
411+
futures::stream::iter([Ok(LlmResponseStreamEvent::new(vec![
412+
LlmResponseChunk::DecodeError {
413+
message: "bad judge chunk".to_string(),
414+
},
415+
]))])
416+
.boxed(),
417+
),
418+
metadata: None,
419+
}),
420+
}
421+
}
422+
}
423+
372424
#[async_trait]
373425
impl RoutedLlmClient for UsageClient {
374426
async fn call(
@@ -453,6 +505,38 @@ fn algo(name: &str, model: &str, client: Option<Arc<dyn RoutedLlmClient>>) -> Ar
453505
})
454506
}
455507

508+
fn classifier_router(
509+
judge_model: &str,
510+
efficient_model: &str,
511+
capable_model: &str,
512+
client: Arc<dyn RoutedLlmClient>,
513+
) -> switchyard_libsy::Result<Arc<dyn Algorithm>> {
514+
let target = |name: &str| LlmTarget {
515+
semantic_name: name.to_string(),
516+
llm_client: Some(client.clone()),
517+
};
518+
let targets = LlmTargetSet::new(vec![target(efficient_model), target(capable_model)]);
519+
Ok(Arc::new(LlmTaskClassifier::new(
520+
LlmClassifierConfig::Capability {
521+
judge_target: target(judge_model),
522+
efficient_target: targets.get_target(efficient_model)?,
523+
capable_target: targets.get_target(capable_model)?,
524+
config: TaskClassifierConfig {
525+
base_threshold: 0.5,
526+
..TaskClassifierConfig::default()
527+
},
528+
},
529+
)?))
530+
}
531+
532+
fn classifier_request() -> Request {
533+
Request {
534+
llm_request: text_request(Some("auto".to_string()), "classify this"),
535+
raw_request: None,
536+
metadata: None,
537+
}
538+
}
539+
456540
fn find_span(spans: &[SpanRecord], name: &str, field: &str, value: &str) -> SpanRecord {
457541
match spans
458542
.iter()
@@ -1032,34 +1116,10 @@ async fn classifier_metrics_count_only_the_final_routed_call() -> switchyard_lib
10321116
let client = Arc::new(ClassifierClient {
10331117
classifier_delay: Duration::from_millis(60),
10341118
routed_delay: Duration::from_millis(200),
1035-
});
1036-
let target = |name: &str| LlmTarget {
1037-
semantic_name: name.to_string(),
1038-
llm_client: Some(client.clone()),
1039-
};
1040-
let targets = LlmTargetSet::new(vec![target("weak"), target("strong")]);
1041-
let weak = targets.get_target("weak")?;
1042-
let strong = targets.get_target("strong")?;
1043-
let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1044-
judge_target: target("classifier"),
1045-
efficient_target: weak,
1046-
capable_target: strong,
1047-
config: TaskClassifierConfig {
1048-
base_threshold: 0.5,
1049-
..TaskClassifierConfig::default()
1050-
},
1051-
})?);
1052-
1053-
let (trace, _response) = router
1054-
.run(
1055-
Context::default(),
1056-
Request {
1057-
llm_request: text_request(Some("auto".to_string()), "classify this"),
1058-
raw_request: None,
1059-
metadata: None,
1060-
},
1061-
)
1062-
.await?;
1119+
}) as Arc<dyn RoutedLlmClient>;
1120+
let router = classifier_router("classifier", "weak", "strong", client)?;
1121+
1122+
let (trace, _response) = router.run(Context::default(), classifier_request()).await?;
10631123

10641124
assert_eq!(
10651125
trace.last().and_then(|decision| decision.routing_tier()),
@@ -1121,3 +1181,60 @@ async fn classifier_metrics_count_only_the_final_routed_call() -> switchyard_lib
11211181
);
11221182
Ok(())
11231183
}
1184+
1185+
#[tokio::test]
1186+
async fn classifier_fail_open_records_each_failure_stage() -> switchyard_libsy::Result<()> {
1187+
let _guard = serialize_test().lock().await;
1188+
let (_store, exporter, provider, _, _) = telemetry();
1189+
1190+
let cases = [
1191+
("fo-call", JudgeOutcome::CallFailure, Some("upstream_5xx")),
1192+
(
1193+
"fo-parse",
1194+
JudgeOutcome::Reply("not json at all"),
1195+
Some("parse_error"),
1196+
),
1197+
(
1198+
"fo-stream-decode",
1199+
JudgeOutcome::StreamDecodeFailure,
1200+
Some("invalid_response"),
1201+
),
1202+
(
1203+
"fo-valid",
1204+
JudgeOutcome::Reply(
1205+
r#"{"recommended_route":"strong","p_solve":0.3,"confidence":0.9,"abstain":false,"capability_boundary":"supported","primary_rule":"CAP-1","crux":"hard task"}"#,
1206+
),
1207+
None,
1208+
),
1209+
];
1210+
1211+
for (judge_model, outcome, expected_reason) in cases {
1212+
let client = Arc::new(JudgeClient { outcome }) as Arc<dyn RoutedLlmClient>;
1213+
classifier_router(judge_model, "fo-weak", "fo-strong", client)?
1214+
.run(Context::default(), classifier_request())
1215+
.await?;
1216+
1217+
let snapshots = flushed_metrics(exporter, provider);
1218+
match expected_reason {
1219+
Some(reason) => assert_eq!(
1220+
u64_counter_value(
1221+
&snapshots,
1222+
"switchyard.classifier_fail_open",
1223+
&[("reason", reason), ("judge_model", judge_model)],
1224+
),
1225+
Some(1),
1226+
"case {reason} did not count the fail-open"
1227+
),
1228+
None => assert_eq!(
1229+
u64_counter_value(
1230+
&snapshots,
1231+
"switchyard.classifier_fail_open",
1232+
&[("judge_model", judge_model)],
1233+
),
1234+
None,
1235+
"a valid verdict was counted as a fail-open"
1236+
),
1237+
}
1238+
}
1239+
Ok(())
1240+
}

crates/switchyard-server/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,18 @@ Routed-call compatibility metrics are:
148148
| `switchyard_reasoning_tokens_total` | counter | `model`, optional `tier` | Reasoning output tokens |
149149
| `switchyard_total_latency_ms` | histogram | `model`, optional `tier` | Full-turn latency for successful routed responses |
150150
| `switchyard_routing_overhead_ms` | histogram | `algorithm` | Algorithm run time minus the call that served it |
151+
| `switchyard_classifier_fail_open_total` | counter | `judge_model`, `reason` | Judge failures that made a classifier route without a verdict |
151152
| `switchyard_client_responses_total` | counter | `outcome` | Final LLM-route responses |
152153
| `switchyard_upstream_attempts_total` | counter | `outcome`, `code` | Actual upstream HTTP attempts |
153154
| `switchyard_router_retry_recovered_total` | counter | none | Retry recoveries (currently always zero) |
154155

155156
The `tier` label is `strong` or `weak` for a distinguishable built-in LLM-classifier decision and
156157
is omitted for untiered algorithms. Classifier calls are excluded from these families.
157158

159+
`switchyard_classifier_fail_open_total` counts requests that still reached a target after the
160+
judge call failed. `judge_model` names the configured judge target, and `reason` is one of eight
161+
fixed error categories.
162+
158163
`switchyard_total_latency_ms` observes an aggregate when it becomes available or a stream when it
159164
ends cleanly. Its clock starts in a router-wide middleware, before the request body is read and
160165
decoded, so it covers the same span as the Python server's request-ingress-to-completion

0 commit comments

Comments
 (0)