From 9ce3ab63692e4208c038382b0ff4a959ec8a0b74 Mon Sep 17 00:00:00 2001 From: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:47:59 +0800 Subject: [PATCH] feat(server): record request origin in routing logs Signed-off-by: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> --- crates/switchyard-server/README.md | 5 +++ crates/switchyard-server/src/routing_log.rs | 39 ++++++++++++++++++++- crates/switchyard-server/tests/server.rs | 20 +++++++++-- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/switchyard-server/README.md b/crates/switchyard-server/README.md index 87c281886..476880076 100644 --- a/crates/switchyard-server/README.md +++ b/crates/switchyard-server/README.md @@ -102,6 +102,11 @@ served model. The legacy `proxy_x_session_id` remains a fallback when no normali present. The endpoint returns `404` when the session has no records and is not registered when routing logging is disabled. +Clients can send `x-switchyard-origin: codex-cli` (or another client label) to include an +`origin` field in each routing record. Missing, empty, or non-text header values produce +`"origin": null`. The value is supplied by the caller; it is not inferred from `User-Agent`. +Older records without `origin` remain readable by the session stats endpoint. + An `llm_classifier` route sends each task to `classifier_target` for a capability verdict, then routes to `weak_target` or `strong_target`. Beyond the three targets it accepts these keys; only `base_threshold` is required, and anything the judge cannot decide routes to `strong_target`: diff --git a/crates/switchyard-server/src/routing_log.rs b/crates/switchyard-server/src/routing_log.rs index 2e9dd8637..cd1a21f73 100644 --- a/crates/switchyard-server/src/routing_log.rs +++ b/crates/switchyard-server/src/routing_log.rs @@ -18,6 +18,7 @@ use crate::usage_metrics::token_usage; use crate::{ServerError, ServerResult}; const LEGACY_SESSION_ID_HEADER: &str = "proxy_x_session_id"; +const ORIGIN_HEADER: &str = "x-switchyard-origin"; const TASK_HEADER: &str = "x-switchyard-intake-task"; const TRIAL_ID_HEADER: &str = "x-switchyard-trial-id"; @@ -53,6 +54,7 @@ impl RoutingLog { ts: format_rfc3339_millis(SystemTime::now()).to_string().into(), route_id: context.route_id.into(), algorithm: context.algorithm.into(), + origin: context.origin.map(Cow::Owned), task: context.task.map(Cow::Owned), trial_id: context.trial_id.map(Cow::Owned), session_id: context.session_id.map(Cow::Owned), @@ -102,6 +104,7 @@ pub(crate) fn snapshot( pub(crate) struct RoutingLogContext { route_id: String, algorithm: String, + origin: Option, task: Option, trial_id: Option, session_id: Option, @@ -114,6 +117,9 @@ impl RoutingLogContext { Self { route_id: String::new(), algorithm: String::new(), + origin: headers + .and_then(|headers| nonempty_header(headers, ORIGIN_HEADER)) + .map(str::to_string), task: headers .and_then(|headers| nonempty_header(headers, TASK_HEADER)) .map(str::to_string), @@ -146,6 +152,8 @@ struct RoutingRecord<'a> { route_id: Cow<'a, str>, algorithm: Cow<'a, str>, #[serde(borrow)] + origin: Option>, + #[serde(borrow)] task: Option>, #[serde(borrow)] trial_id: Option>, @@ -250,6 +258,35 @@ fn routing_log_error(path: &Path, error: std::io::Error) -> ServerError { mod tests { use super::*; + /// Missing, empty, and non-text origin headers remain absent even with a User-Agent. + #[test] + fn unusable_origin_is_not_inferred_from_user_agent() { + for origin in [ + None, + Some(http::HeaderValue::from_static("")), + Some(http::HeaderValue::from_bytes(b"\xff").expect("header")), + ] { + let mut headers = http::HeaderMap::new(); + headers.insert( + "user-agent", + http::HeaderValue::from_static("codex-cli/1.0"), + ); + if let Some(origin) = origin { + headers.insert(ORIGIN_HEADER, origin); + } + let metadata = Metadata { + http_headers: Some(headers), + ..Default::default() + }; + assert!(RoutingLogContext::from_metadata(&metadata).origin.is_none()); + } + assert!( + RoutingLogContext::from_metadata(&Metadata::default()) + .origin + .is_none() + ); + } + /// Only the requested session is counted, absent fields fall back to zero /// and `unknown`, and an unparseable line does not abort the scan. #[test] @@ -264,7 +301,7 @@ mod tests { r#"{"session_id":"b","model":"m1","prompt_tokens":99,"completion_tokens":99}"#, "\n", "not json\n", - r#"{"session_id":"a","prompt_tokens":5}"#, + r#"{"session_id":"a","origin":"custom-agent","prompt_tokens":5}"#, "\n", ), ) diff --git a/crates/switchyard-server/tests/server.rs b/crates/switchyard-server/tests/server.rs index 43e6abf7f..6419a35fb 100644 --- a/crates/switchyard-server/tests/server.rs +++ b/crates/switchyard-server/tests/server.rs @@ -2485,6 +2485,7 @@ async fn routing_log_prefers_canonical_and_preserves_legacy_fallback() -> TestRe .header("content-type", "application/json") .header("x-switchyard-session-id", "canonical-session") .header("proxy_x_session_id", "legacy-session") + .header("x-switchyard-origin", r#"custom-agent/"quoted"\path"#) .body(Body::from(serde_json::to_vec(&json!({ "model": ROUTE_MODEL, "messages": [{"role": "user", "content": "hello"}] @@ -2542,6 +2543,9 @@ async fn routing_log_prefers_canonical_and_preserves_legacy_fallback() -> TestRe let first: Value = serde_json::from_str(records.lines().next().ok_or("routing log was empty")?)?; assert_eq!(first["session_id"], "canonical-session"); + assert_eq!(first["origin"], r#"custom-agent/"quoted"\path"#); + let second: Value = serde_json::from_str(records.lines().nth(1).ok_or("missing record")?)?; + assert_eq!(second.get("origin"), Some(&Value::Null)); assert!( first["ts"] .as_str() @@ -2646,7 +2650,10 @@ async fn routing_log_keeps_the_canonical_session_id_until_a_stream_drains() -> T "messages": [{"role": "user", "content": "hello"}], "stream": true })), - &[("x-switchyard-session-id", "streaming-session")], + &[ + ("x-switchyard-session-id", "streaming-session"), + ("x-switchyard-origin", "codex-cli"), + ], ) .await?; assert_eq!(response.status, StatusCode::OK); @@ -2670,6 +2677,7 @@ async fn routing_log_keeps_the_canonical_session_id_until_a_stream_drains() -> T let record: Value = serde_json::from_str(&std::fs::read_to_string(log_path)?)?; assert_eq!(record["route_id"], ROUTE_MODEL); assert_eq!(record["algorithm"], "random"); + assert_eq!(record["origin"], "codex-cli"); Ok(()) } @@ -3320,7 +3328,10 @@ async fn advisor_route_routing_log_records_classifier_tier() -> TestResult { "POST", "/v1/chat/completions", Some(advisor_chat_body("hi")), - &[("proxy_x_session_id", "session-1")], + &[ + ("proxy_x_session_id", "session-1"), + ("x-switchyard-origin", "custom-agent"), + ], ) .await?; assert_eq!(response.status, StatusCode::OK); @@ -3333,6 +3344,11 @@ async fn advisor_route_routing_log_records_classifier_tier() -> TestResult { // the terminal answer row. The discarded-turn row does not exist in v1 — // its tokens live in the advisor_gate stats block instead. assert_eq!(records.len(), 2); + assert!( + records + .iter() + .all(|record| record["origin"] == "custom-agent") + ); let consult = records .iter() .find(|record| record["model"] == "model/advisor")