Skip to content

Commit 8f296bf

Browse files
committed
feat(observability): log classifier reasoning
Signed-off-by: Todd Fisher <todd.fisher@gmail.com>
1 parent 70abd21 commit 8f296bf

13 files changed

Lines changed: 391 additions & 24 deletions

File tree

crates/libsy-llm-client/src/lib.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ pub mod run;
3030
pub use backend::{Backend, DEFAULT_MAX_RETRIES, HttpBackendConfig};
3131
pub use client::{ModelConfig, TranslatingLlmClient};
3232
pub use error::{LlmClientError, Result};
33-
pub use observation::{LlmCallObservation, LlmCallStartObservation, RunObservation, RunObserver};
33+
pub use observation::{
34+
ClassifierContentObservation, LlmCallObservation, LlmCallStartObservation, RunObservation,
35+
RunObserver,
36+
};
3437
pub use raw::RawResponse;
35-
pub use run::{ClientRouter, run};
38+
pub use run::{ClientRouter, ObservationConfig, run, run_with_observation_config};
3639
pub use switchyard_translation::RawEventStream;

crates/libsy-llm-client/src/observation.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,24 @@
66
use std::sync::Arc;
77
use std::time::Duration;
88

9-
use switchyard_protocol::{Decision, ModelId, Usage};
9+
use switchyard_protocol::{Decision, LlmRequest, ModelId, Usage};
10+
11+
/// Prompt and model-produced content from one non-answer classifier or judge call.
12+
#[derive(Clone, Debug)]
13+
pub struct ClassifierContentObservation {
14+
/// Model that produced the routing verdict.
15+
pub selected_model: ModelId,
16+
/// Exact normalized request sent to the classifier target, excluding transport headers.
17+
pub request: LlmRequest,
18+
/// Model-produced reasoning content, when the provider returned it separately.
19+
pub reasoning: Option<String>,
20+
/// Text verdict consumed by the routing policy, including invalid replies.
21+
pub verdict: Option<String>,
22+
/// Whether the provider call itself completed successfully.
23+
pub is_success: bool,
24+
/// Time spent waiting for the classifier call to resolve.
25+
pub duration: Duration,
26+
}
1027

1128
/// One model call observed immediately before it is sent to its routed client.
1229
#[derive(Clone, Debug)]
@@ -39,6 +56,8 @@ pub enum RunObservation {
3956
RoutingDecision(Decision),
4057
/// A model call about to start.
4158
LlmCallStarted(LlmCallStartObservation),
59+
/// Prompt, reasoning, and verdict from a classifier or judge call.
60+
ClassifierContent(ClassifierContentObservation),
4261
/// A completed model call.
4362
LlmCall(LlmCallObservation),
4463
/// Routing time recorded by the `switchyard.routing_overhead_ms` metric.

crates/libsy-llm-client/src/run.rs

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ use std::time::{Duration, Instant};
2020
use parking_lot::Mutex;
2121
use switchyard_libsy::{Algorithm, CallModel, LibsyError, Result, drive_with_decision_observer};
2222
use switchyard_protocol::{
23-
Decision, LlmClientError, ModelId, Request, Response, RoutedLlmClient, RoutingFallbackReason,
23+
ContentBlock, Decision, LlmClientError, ModelId, Request, Response, RoutedLlmClient,
24+
RoutingFallbackReason, completion_text,
2425
};
2526

2627
use crate::observation::{
27-
LlmCallObservation, LlmCallStartObservation, RunObservation, RunObserver,
28+
ClassifierContentObservation, LlmCallObservation, LlmCallStartObservation, RunObservation,
29+
RunObserver,
2830
};
2931
use 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.
276356
fn fallback_reason(error: &LibsyError) -> Option<RoutingFallbackReason> {
277357
let LibsyError::ClientCall { source, .. } = error else {

crates/libsy-llm-client/tests/observability.rs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,8 +343,18 @@ impl RoutedLlmClient for ClassifierClient {
343343
tokio::time::sleep(self.classifier_delay).await;
344344
r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#
345345
};
346+
let mut response = text_response(Some(model_id.to_string()), completion);
347+
if model_id == self.classifier_model_id {
348+
response.outputs[0].content.insert(
349+
0,
350+
ContentBlock::Reasoning {
351+
text: "The task is bounded and supported.".to_string(),
352+
signature: None,
353+
},
354+
);
355+
}
346356
Ok(Response {
347-
llm_response: LlmResponse::Agg(text_response(Some(model_id.to_string()), completion)),
357+
llm_response: LlmResponse::Agg(response),
348358
metadata: None,
349359
})
350360
}
@@ -1292,6 +1302,57 @@ async fn classifier_metrics_count_only_the_final_routed_call() -> switchyard_lib
12921302
Ok(())
12931303
}
12941304

1305+
#[tokio::test]
1306+
async fn observed_classifier_call_reports_prompt_reasoning_and_verdict()
1307+
-> switchyard_libsy::Result<()> {
1308+
let _guard = serialize_test().lock().await;
1309+
let observations = Arc::new(Mutex::new(Vec::new()));
1310+
let observed = Arc::clone(&observations);
1311+
let observer: RunObserver = Arc::new(move |observation| observed.lock().push(observation));
1312+
let client = Arc::new(ClassifierClient {
1313+
classifier_model_id: "classifier".into(),
1314+
classifier_delay: Duration::ZERO,
1315+
routed_delay: Duration::ZERO,
1316+
}) as Arc<dyn RoutedLlmClient>;
1317+
1318+
switchyard_llm_client::run_with_observation_config(
1319+
classifier_router("classifier", "weak", "strong")?,
1320+
ClientRouter::single(client),
1321+
classifier_request(),
1322+
Some(observer),
1323+
switchyard_llm_client::ObservationConfig {
1324+
classifier_content: true,
1325+
},
1326+
)
1327+
.await?;
1328+
1329+
let observations = observations.lock();
1330+
let content = observations
1331+
.iter()
1332+
.find_map(|observation| match observation {
1333+
RunObservation::ClassifierContent(content) => Some(content),
1334+
_ => None,
1335+
})
1336+
.ok_or_else(|| test_error("expected classifier content observation"))?;
1337+
assert_eq!(content.selected_model, "classifier");
1338+
assert_eq!(
1339+
content.reasoning.as_deref(),
1340+
Some("The task is bounded and supported.")
1341+
);
1342+
assert_eq!(
1343+
content.verdict.as_deref(),
1344+
Some(
1345+
r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#
1346+
)
1347+
);
1348+
assert_eq!(
1349+
content.request.messages,
1350+
classifier_request().llm_request.messages
1351+
);
1352+
assert!(content.is_success);
1353+
Ok(())
1354+
}
1355+
12951356
#[tokio::test]
12961357
async fn classifier_fail_open_records_each_failure_stage() -> switchyard_libsy::Result<()> {
12971358
let _guard = serialize_test().lock().await;

crates/switchyard-server/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ served model. The legacy `proxy_x_session_id` remains a fallback when no normali
102102
present. The endpoint returns `404` when the session has no records and is not registered when
103103
routing logging is disabled.
104104

105+
Add `--routing-log-classifier-content` to append a `classifier_content` event containing the
106+
normalized classifier request, provider-returned reasoning, and raw verdict. This is opt-in
107+
because the classifier request and reasoning can repeat user-provided secrets. Transport headers
108+
and provider credentials are never included. Store this log with restricted permissions and
109+
rotation appropriate for sensitive request content.
110+
105111
An `llm_classifier` route sends each task to `classifier_target` for a capability verdict, then
106112
routes to `weak_target` or `strong_target`. Beyond the three targets it accepts these keys; only
107113
`base_threshold` is required, and anything the judge cannot decide routes to `strong_target`:

crates/switchyard-server/src/cli.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ pub(crate) struct ServerArgs {
5252
#[arg(long, value_name = "PATH")]
5353
routing_log_file: Option<PathBuf>,
5454

55+
/// Include classifier prompts, model reasoning, and verdicts in the routing log.
56+
#[arg(long, requires = "routing_log_file")]
57+
routing_log_classifier_content: bool,
58+
5559
/// TLS certificate path in PEM format.
5660
#[arg(long, requires = "tls_key")]
5761
tls_cert: Option<PathBuf>,
@@ -72,6 +76,7 @@ impl ServerArgs {
7276
if let Some(path) = self.routing_log_file {
7377
state = state.with_routing_log(path)?;
7478
}
79+
state = state.with_routing_log_classifier_content(self.routing_log_classifier_content);
7580
let tls = match (self.tls_cert, self.tls_key) {
7681
(Some(cert), Some(key)) => {
7782
if !cert.exists() || !key.exists() {

0 commit comments

Comments
 (0)