@@ -20,11 +20,13 @@ use std::time::{Duration, Instant};
2020use parking_lot:: Mutex ;
2121use switchyard_libsy:: { Algorithm , CallModel , LibsyError , Result , drive_with_decision_observer} ;
2222use switchyard_protocol:: {
23- Decision , LlmClientError , ModelId , Request , Response , RoutedLlmClient , RoutingFallbackReason ,
23+ ContentBlock , Decision , LlmClientError , ModelId , Request , Response , RoutedLlmClient ,
24+ RoutingFallbackReason , completion_text,
2425} ;
2526
2627use crate :: observation:: {
27- LlmCallObservation , LlmCallStartObservation , RunObservation , RunObserver ,
28+ ClassifierContentObservation , LlmCallObservation , LlmCallStartObservation , RunObservation ,
29+ RunObserver ,
2830} ;
2931use crate :: { metrics, observability} ;
3032
@@ -46,6 +48,31 @@ pub async fn run(
4648 clients : ClientRouter ,
4749 request : Request ,
4850 observer : Option < RunObserver > ,
51+ ) -> Result < ( Vec < Decision > , Response ) > {
52+ run_with_observation_config (
53+ algorithm,
54+ clients,
55+ request,
56+ observer,
57+ ObservationConfig :: default ( ) ,
58+ )
59+ . await
60+ }
61+
62+ /// Controls optional content captured for a [`RunObserver`].
63+ #[ derive( Clone , Copy , Debug , Default ) ]
64+ pub struct ObservationConfig {
65+ /// Capture normalized prompts, reasoning, and verdicts for non-answer calls.
66+ pub classifier_content : bool ,
67+ }
68+
69+ /// Runs one request like [`run`] with explicit observation-content controls.
70+ pub async fn run_with_observation_config (
71+ algorithm : Arc < dyn Algorithm > ,
72+ clients : ClientRouter ,
73+ request : Request ,
74+ observer : Option < RunObserver > ,
75+ observation_config : ObservationConfig ,
4976) -> Result < ( Vec < Decision > , Response ) > {
5077 let algorithm_name = algorithm. name ( ) . to_string ( ) ;
5178 // The output from `serve` goes in here: when each successful routed call was in
@@ -64,6 +91,7 @@ pub async fn run(
6491 clients. clone ( ) ,
6592 call,
6693 observer. clone ( ) ,
94+ observation_config,
6795 Arc :: clone ( & routed_calls) ,
6896 )
6997 }
@@ -122,10 +150,18 @@ async fn serve(
122150 clients : ClientRouter ,
123151 call : CallModel ,
124152 observer : Option < RunObserver > ,
153+ observation_config : ObservationConfig ,
125154 // Output parameter because `drive` takes a function that returns a plain `Result<()>`.
126155 routed_calls : Arc < Mutex < RoutedCallWindows > > ,
127156) -> Result < ( ) > {
128- let result = call_first_available ( & clients, & call, & observer, & routed_calls) . await ;
157+ let result = call_first_available (
158+ & clients,
159+ & call,
160+ & observer,
161+ observation_config,
162+ & routed_calls,
163+ )
164+ . await ;
129165 call. respond ( result)
130166}
131167
@@ -134,6 +170,7 @@ async fn call_first_available(
134170 clients : & ClientRouter ,
135171 call : & CallModel ,
136172 observer : & Option < RunObserver > ,
173+ observation_config : ObservationConfig ,
137174 routed_calls : & Arc < Mutex < RoutedCallWindows > > ,
138175) -> Result < Response > {
139176 for ( index, target) in call. models . iter ( ) . enumerate ( ) {
@@ -144,6 +181,7 @@ async fn call_first_available(
144181 request,
145182 call,
146183 observer,
184+ observation_config,
147185 routed_calls,
148186 index,
149187 call. models . len ( ) ,
@@ -212,6 +250,7 @@ async fn call_one(
212250 request : Request ,
213251 call : & CallModel ,
214252 observer : & Option < RunObserver > ,
253+ observation_config : ObservationConfig ,
215254 routed_calls : & Arc < Mutex < RoutedCallWindows > > ,
216255 // index is for span log
217256 index : usize ,
@@ -229,6 +268,9 @@ async fn call_one(
229268 span. record ( "gen_ai.conversation.id" , session_id) ;
230269 }
231270 let is_answer_call = call. is_answer_call ;
271+ let classifier_request =
272+ ( observer. is_some ( ) && observation_config. classifier_content && !is_answer_call)
273+ . then ( || request. llm_request . clone ( ) ) ;
232274 // Resolved before the clock starts: picking the client is Switchyard's work, not
233275 // the provider's, so it belongs in the routing overhead.
234276 let client = clients. route ( model_id) ;
@@ -253,6 +295,22 @@ async fn call_one(
253295 } ) ;
254296 let result = observability:: observe_client_call ( result) ;
255297 if let Some ( observer) = observer {
298+ if let Some ( request) = classifier_request {
299+ let aggregate = result
300+ . as_ref ( )
301+ . ok ( )
302+ . and_then ( |response| response. llm_response . as_agg ( ) ) ;
303+ observer ( RunObservation :: ClassifierContent (
304+ ClassifierContentObservation {
305+ selected_model : model_id. clone ( ) ,
306+ request,
307+ reasoning : aggregate. and_then ( classifier_reasoning) ,
308+ verdict : aggregate. and_then ( classifier_verdict) ,
309+ is_success : result. is_ok ( ) ,
310+ duration,
311+ } ,
312+ ) ) ;
313+ }
256314 observer ( RunObservation :: LlmCall ( LlmCallObservation {
257315 selected_model : model_id. clone ( ) ,
258316 is_answer_call,
@@ -272,6 +330,28 @@ async fn call_one(
272330 result
273331}
274332
333+ fn classifier_reasoning ( response : & switchyard_protocol:: AggLlmResponse ) -> Option < String > {
334+ nonempty_join ( response. outputs . iter ( ) . flat_map ( |output| {
335+ output. content . iter ( ) . filter_map ( |block| match block {
336+ ContentBlock :: Reasoning { text, .. } => Some ( text. as_str ( ) ) ,
337+ _ => None ,
338+ } )
339+ } ) )
340+ }
341+
342+ fn classifier_verdict ( response : & switchyard_protocol:: AggLlmResponse ) -> Option < String > {
343+ let verdict = completion_text ( response) ;
344+ ( !verdict. is_empty ( ) ) . then_some ( verdict)
345+ }
346+
347+ fn nonempty_join < ' a > ( parts : impl Iterator < Item = & ' a str > ) -> Option < String > {
348+ let joined = parts
349+ . filter ( |part| !part. is_empty ( ) )
350+ . collect :: < Vec < _ > > ( )
351+ . join ( "\n " ) ;
352+ ( !joined. is_empty ( ) ) . then_some ( joined)
353+ }
354+
275355/// Whether a failed candidate is worth routing around.
276356fn fallback_reason ( error : & LibsyError ) -> Option < RoutingFallbackReason > {
277357 let LibsyError :: ClientCall { source, .. } = error else {
0 commit comments