Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/switchyard-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
39 changes: 38 additions & 1 deletion crates/switchyard-server/src/routing_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -102,6 +104,7 @@ pub(crate) fn snapshot(
pub(crate) struct RoutingLogContext {
route_id: String,
algorithm: String,
origin: Option<String>,
task: Option<String>,
trial_id: Option<String>,
session_id: Option<String>,
Expand All @@ -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),
Expand Down Expand Up @@ -146,6 +152,8 @@ struct RoutingRecord<'a> {
route_id: Cow<'a, str>,
algorithm: Cow<'a, str>,
#[serde(borrow)]
origin: Option<Cow<'a, str>>,
#[serde(borrow)]
task: Option<Cow<'a, str>>,
#[serde(borrow)]
trial_id: Option<Cow<'a, str>>,
Expand Down Expand Up @@ -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]
Expand All @@ -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",
),
)
Expand Down
20 changes: 18 additions & 2 deletions crates/switchyard-server/tests/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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);
Expand All @@ -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(())
}

Expand Down Expand Up @@ -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);
Expand All @@ -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")
Expand Down