@@ -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]
373425impl 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+
456540fn 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+ }
0 commit comments