diff --git a/crates/libsy-llm-client/README.md b/crates/libsy-llm-client/README.md index a1d5bbbc..a9b4d935 100644 --- a/crates/libsy-llm-client/README.md +++ b/crates/libsy-llm-client/README.md @@ -76,11 +76,14 @@ fn build_client() -> switchyard_llm_client::Result { ```rust use switchyard_llm_client::{LlmClientError, TranslatingLlmClient}; -use switchyard_protocol::{completion_text, text_request, Context, LlmResponse, Request}; +use switchyard_protocol::{ContentBlock, Context, LlmRequest, LlmResponse, Message, Request, Role}; async fn ask(client: &TranslatingLlmClient) -> switchyard_llm_client::Result { let request = Request { - llm_request: text_request(None, "Say hello in five words."), + llm_request: LlmRequest { + messages: vec![Message::text(Role::User, "Say hello in five words.")], + ..LlmRequest::default() + }, raw_request: None, metadata: None, }; @@ -91,7 +94,15 @@ async fn ask(client: &TranslatingLlmClient) -> switchyard_llm_client::Result Ok(completion_text(&agg)), + LlmResponse::Agg(agg) => Ok(agg + .outputs + .first() + .and_then(|output| output.content.first()) + .and_then(|block| match block { + ContentBlock::Text { text } => Some(text.clone()), + _ => None, + }) + .unwrap_or_default()), LlmResponse::Stream(_) => Err(LlmClientError::InvalidResponse { source: "expected a buffered response".into(), }), @@ -106,13 +117,16 @@ Set `stream` on the IR request and drive the returned chunk stream: ```rust use futures_util::StreamExt; use switchyard_llm_client::TranslatingLlmClient; -use switchyard_protocol::{text_request, Context, LlmResponse, LlmResponseChunk, Request}; +use switchyard_protocol::{Context, LlmRequest, LlmResponse, LlmResponseChunk, Message, Request, Role}; async fn stream( client: &TranslatingLlmClient, ) -> Result<(), Box> { - let mut llm_request = text_request(None, "Count to five."); - llm_request.stream = true; + let llm_request = LlmRequest { + messages: vec![Message::text(Role::User, "Count to five.")], + stream: true, + ..LlmRequest::default() + }; let request = Request { llm_request, raw_request: None, metadata: None }; let response = client diff --git a/crates/libsy-llm-client/src/client.rs b/crates/libsy-llm-client/src/client.rs index 67f778af..b063c30d 100644 --- a/crates/libsy-llm-client/src/client.rs +++ b/crates/libsy-llm-client/src/client.rs @@ -772,13 +772,38 @@ mod tests { use std::thread::JoinHandle; use serde_json::json; - use switchyard_protocol::{LlmRequest, completion_text, text_request}; + use switchyard_protocol::{AggLlmResponse, ContentBlock, LlmRequest, Message, Role}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use super::*; use crate::backend::HttpBackendConfig; + fn text_request(model: Option, prompt: impl Into) -> LlmRequest { + LlmRequest { + model, + messages: vec![Message::text(Role::User, prompt)], + ..LlmRequest::default() + } + } + + fn completion_text(response: &AggLlmResponse) -> String { + response + .outputs + .first() + .map(|output| { + output + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::() + }) + .unwrap_or_default() + } + fn config(base_url: &str) -> HttpBackendConfig { HttpBackendConfig { base_url: base_url.to_string(), diff --git a/crates/libsy/examples/ensemble.rs b/crates/libsy/examples/ensemble.rs index 7f96b1d8..4f0cf373 100644 --- a/crates/libsy/examples/ensemble.rs +++ b/crates/libsy/examples/ensemble.rs @@ -27,12 +27,11 @@ use parking_lot::Mutex; use tokio::sync::Notify; +mod support; +use support::{completion_text, prompt_text, text_request}; use switchyard_libsy::{Algorithm, Driver, LibsyError, LlmTarget, LlmTargetSet, Result}; use switchyard_llm_client::{Backend, HttpBackendConfig, ModelConfig, TranslatingLlmClient}; -use switchyard_protocol::{ - Context, Decision, LlmResponse, Request, Response, RoutedLlmClient, completion_text, - prompt_text, text_request, -}; +use switchyard_protocol::{Context, Decision, LlmResponse, Request, Response, RoutedLlmClient}; const CANDIDATE_MODELS: [&str; 3] = [ "nvidia/qwen/qwen3.6-27b", @@ -553,12 +552,12 @@ mod tests { use super::*; use std::sync::atomic::{AtomicBool, Ordering}; + use crate::support::{completion_text, prompt_text, text_request, text_response}; use futures::StreamExt; use switchyard_libsy::LlmTarget; use switchyard_protocol::{ LlmRequest, LlmResponse, LlmResponseChunk, LlmResponseStreamEvent, Message, Response, Role, - RoutedLlmClient, SamplingParams, Signals, ToolChoice, ToolDefinition, completion_text, - prompt_text, text_request, text_response, + RoutedLlmClient, SamplingParams, Signals, ToolChoice, ToolDefinition, }; use tokio::sync::Semaphore; diff --git a/crates/libsy/examples/research_agent.rs b/crates/libsy/examples/research_agent.rs index 81d651cd..be0442db 100644 --- a/crates/libsy/examples/research_agent.rs +++ b/crates/libsy/examples/research_agent.rs @@ -14,14 +14,13 @@ use std::sync::Arc; use async_trait::async_trait; +mod support; +use support::{completion_text, text_request, text_response}; use switchyard_libsy::{ Algorithm, LibsyError, LlmClassifierConfig, LlmTarget, LlmTargetSet, LlmTaskClassifier, Result, TaskClassifierConfig, }; -use switchyard_protocol::{ - Context, Decision, LlmResponse, Request, Response, RoutedLlmClient, completion_text, - text_request, text_response, -}; +use switchyard_protocol::{Context, Decision, LlmResponse, Request, Response, RoutedLlmClient}; const CLASSIFIER: &str = "classifier/model"; const STRONG: &str = "strong/model"; diff --git a/crates/libsy/examples/research_agent_core.rs b/crates/libsy/examples/research_agent_core.rs index 6a655873..271d3964 100644 --- a/crates/libsy/examples/research_agent_core.rs +++ b/crates/libsy/examples/research_agent_core.rs @@ -11,13 +11,13 @@ use std::sync::Arc; +mod support; +use support::{completion_text, text_request, text_response}; use switchyard_libsy::{ Algorithm, LibsyError, LlmClassifierConfig, LlmTarget, LlmTargetSet, LlmTaskClassifier, Result, Step, TaskClassifierConfig, }; -use switchyard_protocol::{ - Context, Decision, LlmResponse, Request, Response, completion_text, text_request, text_response, -}; +use switchyard_protocol::{Context, Decision, LlmResponse, Request, Response}; use tokio_stream::StreamExt; const CLASSIFIER: &str = "classifier/model"; diff --git a/crates/libsy/examples/streaming_agent.rs b/crates/libsy/examples/streaming_agent.rs index 47a3918a..adcba2bc 100644 --- a/crates/libsy/examples/streaming_agent.rs +++ b/crates/libsy/examples/streaming_agent.rs @@ -17,10 +17,11 @@ use std::io::Write; use std::sync::Arc; use futures::StreamExt; +mod support; +use support::{completion_text, text_request}; use switchyard_libsy::{Algorithm, LibsyError, LlmTarget, LlmTargetSet, Random, Result, Step}; use switchyard_protocol::{ - Context, LlmResponse, LlmResponseChunk, LlmResponseStream, Request, Response, completion_text, - text_request, + Context, LlmResponse, LlmResponseChunk, LlmResponseStream, Request, Response, }; /// The "real" model call the agent makes to fulfill an offloaded promise: a response diff --git a/crates/libsy/examples/support/mod.rs b/crates/libsy/examples/support/mod.rs new file mode 100644 index 00000000..38782143 --- /dev/null +++ b/crates/libsy/examples/support/mod.rs @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![allow(dead_code)] + +use switchyard_protocol::{ + AggLlmResponse, ContentBlock, LlmRequest, Message, ResponseOutput, Role, +}; + +pub fn text_request(model: Option, prompt: impl Into) -> LlmRequest { + LlmRequest { + model, + messages: vec![Message::text(Role::User, prompt)], + ..LlmRequest::default() + } +} + +pub fn prompt_text(request: &LlmRequest) -> String { + request + .messages + .iter() + .filter(|message| message.role == Role::User) + .filter_map(|message| message.text_content("\n")) + .collect::>() + .join("\n") +} + +pub fn text_response(model: Option, completion: impl Into) -> AggLlmResponse { + AggLlmResponse { + model, + outputs: vec![ResponseOutput { + role: Role::Assistant, + content: vec![ContentBlock::Text { + text: completion.into(), + }], + stop_reason: None, + }], + ..AggLlmResponse::default() + } +} + +pub fn completion_text(response: &AggLlmResponse) -> String { + response + .outputs + .first() + .map(|output| { + output + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::() + }) + .unwrap_or_default() +} diff --git a/crates/libsy/src/algorithms/fall_through.rs b/crates/libsy/src/algorithms/fall_through.rs index 1b41f08e..06ab9bcd 100644 --- a/crates/libsy/src/algorithms/fall_through.rs +++ b/crates/libsy/src/algorithms/fall_through.rs @@ -353,12 +353,10 @@ mod tests { use super::*; use crate::algorithms::util::prompts; use crate::core::classifier::Classification; + use crate::text::{completion_text, text_request, text_response}; use crate::{SystemPromptProcessor, TargetPrompts}; - use switchyard_protocol::{ - LlmClientError, LlmRequest, LlmResponse, Message, Metadata, Role, completion_text, - text_request, text_response, - }; + use switchyard_protocol::{LlmClientError, LlmRequest, LlmResponse, Message, Metadata, Role}; #[derive(Debug, thiserror::Error)] #[error("{0}")] diff --git a/crates/libsy/src/algorithms/llm_class.rs b/crates/libsy/src/algorithms/llm_class.rs index 6ec775f2..a45b1ab7 100644 --- a/crates/libsy/src/algorithms/llm_class.rs +++ b/crates/libsy/src/algorithms/llm_class.rs @@ -915,9 +915,8 @@ mod tests { use serde_json::Value; use super::*; - use switchyard_protocol::{ - LlmClientError, LlmRequest, Metadata, completion_text, text_request, text_response, - }; + use crate::text::{completion_text, text_request, text_response}; + use switchyard_protocol::{LlmClientError, LlmRequest, Metadata}; use crate::algorithms::util::llm_judge::Judge; use crate::core::algorithm::Algorithm; diff --git a/crates/libsy/src/algorithms/noop.rs b/crates/libsy/src/algorithms/noop.rs index 71118cd4..95586c59 100644 --- a/crates/libsy/src/algorithms/noop.rs +++ b/crates/libsy/src/algorithms/noop.rs @@ -17,7 +17,7 @@ use switchyard_protocol::{Context, Decision}; pub struct Noop {} /// Test decision carrying the inbound model or a fixed placeholder. -pub struct NoopDecision { +pub(crate) struct NoopDecision { model: String, } diff --git a/crates/libsy/src/algorithms/passthrough.rs b/crates/libsy/src/algorithms/passthrough.rs index 2a922f28..bb9c9352 100644 --- a/crates/libsy/src/algorithms/passthrough.rs +++ b/crates/libsy/src/algorithms/passthrough.rs @@ -24,7 +24,7 @@ impl Passthrough { } /// Decision emitted before [`Passthrough`] calls its configured target. -pub struct PassthroughDecision { +pub(crate) struct PassthroughDecision { model_id: String, } @@ -77,10 +77,8 @@ mod tests { use super::Passthrough; use crate::core::algorithm::{Algorithm, LlmTarget}; - use switchyard_protocol::{ - Context, Decision, LlmResponse, Request, Response, RoutedLlmClient, completion_text, - text_request, text_response, - }; + use crate::text::{completion_text, text_request, text_response}; + use switchyard_protocol::{Context, Decision, LlmResponse, Request, Response, RoutedLlmClient}; /// Echoes the selected target so tests can inspect which target was called. /// TODO: Duplicated from rand.rs diff --git a/crates/libsy/src/algorithms/rand.rs b/crates/libsy/src/algorithms/rand.rs index 2eb93f02..cc9c2c1d 100644 --- a/crates/libsy/src/algorithms/rand.rs +++ b/crates/libsy/src/algorithms/rand.rs @@ -15,15 +15,12 @@ use rand::SeedableRng; use rand::distr::{Distribution, weighted::WeightedIndex}; use rand::rngs::StdRng; -use crate::algorithms::fall_through::{FallThrough, FallThroughDecision}; +use crate::algorithms::fall_through::FallThrough; use crate::core::algorithm::{Algorithm, Driver, LlmTargetSet}; use crate::core::classifier::{Classification, Classifier, Score}; use crate::{LibsyError, Result}; use switchyard_protocol::{Context, Request, Response, RoutedLlmClient}; -/// Compatibility name for the decision produced by [`Random`]. -pub type RandomDecision = FallThroughDecision; - /// Stateless weighted classifier used by random fall-through routing. pub struct RandomClassifier { targets: Vec, @@ -181,11 +178,12 @@ mod tests { use super::*; use std::collections::HashSet; - use switchyard_protocol::{Metadata, completion_text, text_request, text_response}; - use crate::DriverError; + use crate::algorithms::fall_through::FallThroughDecision; use crate::algorithms::util::affinity::AffinityRouter; use crate::core::algorithm::LlmTarget; + use crate::text::{completion_text, text_request, text_response}; + use switchyard_protocol::Metadata; use switchyard_protocol::{Decision, LlmResponse, Request, RoutedLlmClient, Signals}; /// Echoes the selected target so tests can inspect which target was called. @@ -456,10 +454,10 @@ mod tests { ); let concrete = decision .as_any() - .downcast_ref::() + .downcast_ref::() .ok_or_else(|| { LibsyError::from(DriverError::TypeMismatch { - expected: "RandomDecision", + expected: "FallThroughDecision", }) })?; assert_eq!(concrete.selected_model, "only/model"); diff --git a/crates/libsy/src/algorithms/stage.rs b/crates/libsy/src/algorithms/stage.rs index aa162d6e..a8172c07 100644 --- a/crates/libsy/src/algorithms/stage.rs +++ b/crates/libsy/src/algorithms/stage.rs @@ -223,11 +223,12 @@ fn build_route( mod tests { use std::sync::Arc; + use crate::text::text_response; use async_trait::async_trait; use parking_lot::Mutex; use serde_json::json; use switchyard_protocol::{ - ContentBlock, LlmRequest, Message, Role, ToolCall, ToolResult, WireFormat, text_response, + ContentBlock, LlmRequest, Message, Role, ToolCall, ToolResult, WireFormat, }; use super::*; diff --git a/crates/libsy/src/algorithms/subagent_affinity_tests.rs b/crates/libsy/src/algorithms/subagent_affinity_tests.rs index 1e719888..c5d0b656 100644 --- a/crates/libsy/src/algorithms/subagent_affinity_tests.rs +++ b/crates/libsy/src/algorithms/subagent_affinity_tests.rs @@ -17,11 +17,34 @@ use super::util::subagent::SubagentOverride; use crate::Result; use crate::core::algorithm::{Algorithm, Driver, LlmTarget, LlmTargetSet}; use crate::core::classifier::{Classification, Classifier, Score}; +use crate::text::{completion_text, text_request, text_response}; use switchyard_protocol::{ - Context, Decision, LlmResponse, Metadata, Request, Response, RoutedLlmClient, completion_text, - slice_to_header_map, text_request, text_response, + Context, Decision, LlmResponse, Metadata, Request, Response, RoutedLlmClient, }; +fn metadata_from_headers(headers: &[(&str, &str)]) -> Metadata { + let mut metadata = Metadata::default(); + for (name, value) in headers { + match *name { + "x-claude-code-session-id" | "x-codex-session-id" => { + metadata.session_id = Some((*value).to_string()); + } + "x-claude-code-agent-id" => { + metadata.agent_id = Some((*value).to_string()); + metadata.is_subagent = true; + metadata.is_delegated_work = true; + } + "x-openai-subagent" => { + metadata.agent_kind = Some((*value).to_string()); + metadata.is_subagent = true; + metadata.is_delegated_work = matches!(*value, "collab_spawn" | "review"); + } + _ => {} + } + } + metadata +} + /// A client that echoes the routed target name back as the completion. struct EchoClient; @@ -77,7 +100,7 @@ fn request(headers: &[(&str, &str)]) -> Request { Request { llm_request: text_request(Some("auto".to_string()), "hi"), raw_request: None, - metadata: Some(Metadata::from_headers(&slice_to_header_map(headers))), + metadata: Some(metadata_from_headers(headers)), } } diff --git a/crates/libsy/src/algorithms/util/affinity.rs b/crates/libsy/src/algorithms/util/affinity.rs index 04aa5922..8941d113 100644 --- a/crates/libsy/src/algorithms/util/affinity.rs +++ b/crates/libsy/src/algorithms/util/affinity.rs @@ -212,12 +212,11 @@ fn evict_if_full(assignments: &mut HashMap) { #[cfg(test)] mod tests { use super::*; + use crate::text::text_request; use std::sync::Arc; - use switchyard_protocol::{ - ContentBlock, Decision, LlmRequest, Message, Metadata, text_request, - }; + use switchyard_protocol::{ContentBlock, Decision, LlmRequest, Message, Metadata}; /// Boxed, thread-safe error type keeping the test helpers ergonomic. type BoxErr = Box; diff --git a/crates/libsy/src/algorithms/util/llm_judge.rs b/crates/libsy/src/algorithms/util/llm_judge.rs index ce73da7e..f733564e 100644 --- a/crates/libsy/src/algorithms/util/llm_judge.rs +++ b/crates/libsy/src/algorithms/util/llm_judge.rs @@ -13,14 +13,13 @@ use std::sync::Arc; use async_trait::async_trait; use serde::de::DeserializeOwned; use serde_json::Value; -use switchyard_protocol::{ - AggLlmResponse, LlmRequest, Message, OutputParams, Role, completion_text, -}; +use switchyard_protocol::{AggLlmResponse, LlmRequest, Message, OutputParams, Role}; use super::classifier_contract::ClassifierContract; use crate::core::algorithm::{Driver, LlmTarget}; use crate::core::classifier::{Classification, Classifier}; use crate::core::state::State; +use crate::text::completion_text; use crate::{LibsyError, Result}; use switchyard_protocol::{Context, Decision, Request, Response}; @@ -333,10 +332,11 @@ mod tests { use futures::StreamExt; use serde::Deserialize; - use switchyard_protocol::{ContentBlock, LlmClientError, text_request, text_response}; + use switchyard_protocol::{ContentBlock, LlmClientError}; use crate::core::algorithm::Step; use crate::core::classifier::Score; + use crate::text::{text_request, text_response}; use switchyard_protocol::{LlmResponse, LlmResponseChunk, Response}; const VERDICT: &str = r#"{"ok":true}"#; diff --git a/crates/libsy/src/algorithms/util/prompts.rs b/crates/libsy/src/algorithms/util/prompts.rs index e4270847..29e8929b 100644 --- a/crates/libsy/src/algorithms/util/prompts.rs +++ b/crates/libsy/src/algorithms/util/prompts.rs @@ -111,7 +111,8 @@ impl Processor for SystemPromptProcessor { #[cfg(test)] mod tests { use super::*; - use switchyard_protocol::{LlmRequest, ToolResult, text_request}; + use crate::text::text_request; + use switchyard_protocol::{LlmRequest, ToolResult}; const NOTE: &str = "recovering from an error"; const STRONG_PROMPT: &str = "diagnose before you edit"; diff --git a/crates/libsy/src/algorithms/util/stage.rs b/crates/libsy/src/algorithms/util/stage.rs index 24e3dede..807b7bf9 100644 --- a/crates/libsy/src/algorithms/util/stage.rs +++ b/crates/libsy/src/algorithms/util/stage.rs @@ -524,8 +524,9 @@ impl Classifier for StageClassifier { #[cfg(test)] mod tests { use super::*; + use crate::text::text_request; use serde_json::json; - use switchyard_protocol::{Metadata, Request, WireFormat, text_request}; + use switchyard_protocol::{Metadata, Request, WireFormat}; fn signal_from(messages: serde_json::Value) -> ToolSignals { let raw_request = Some(json!({"model": "m", "messages": messages})); diff --git a/crates/libsy/src/algorithms/util/subagent.rs b/crates/libsy/src/algorithms/util/subagent.rs index 829d1cb2..5a7a5d5f 100644 --- a/crates/libsy/src/algorithms/util/subagent.rs +++ b/crates/libsy/src/algorithms/util/subagent.rs @@ -74,15 +74,41 @@ where #[cfg(test)] mod tests { use super::*; - use switchyard_protocol::{slice_to_header_map, text_request}; + use crate::text::text_request; + + fn metadata_from_headers(headers: &[(&str, &str)]) -> Option { + if headers.is_empty() { + return None; + } + let mut metadata = Metadata::default(); + for (name, value) in headers { + match *name { + "x-claude-code-session-id" => metadata.session_id = Some((*value).to_string()), + "x-claude-code-agent-id" => { + metadata.agent_id = Some((*value).to_string()); + metadata.is_subagent = true; + metadata.is_delegated_work = true; + } + "x-openai-subagent" => { + metadata.agent_kind = Some((*value).to_string()); + metadata.is_subagent = true; + metadata.is_delegated_work = matches!(*value, "collab_spawn" | "review"); + } + "x-switchyard-is-subagent" if *value == "false" => { + metadata.is_subagent = false; + metadata.is_delegated_work = false; + } + _ => {} + } + } + Some(metadata) + } fn request(headers: &[(&str, &str)]) -> Request { - let metadata = - (!headers.is_empty()).then(|| Metadata::from_headers(&slice_to_header_map(headers))); Request { llm_request: text_request(Some("auto".to_string()), "hi"), raw_request: None, - metadata, + metadata: metadata_from_headers(headers), } } diff --git a/crates/libsy/src/algorithms/util/tool_signals.rs b/crates/libsy/src/algorithms/util/tool_signals.rs index 388417c4..736f4902 100644 --- a/crates/libsy/src/algorithms/util/tool_signals.rs +++ b/crates/libsy/src/algorithms/util/tool_signals.rs @@ -200,7 +200,7 @@ pub const DEFAULT_RECENT_WINDOW: usize = 3; /// Tool-execution signals extracted from a normalized [`Request`]. /// -/// A request-side processor stores these signals in [`State`](crate::State) for +/// A request-side processor stores these signals in libsy's internal state for /// [`crate::StageRouter`] and its classifier to consume. #[derive(Clone, Debug, Default)] pub struct ToolSignals { diff --git a/crates/libsy/src/core/algorithm.rs b/crates/libsy/src/core/algorithm.rs index bd6212ca..4314fd6e 100644 --- a/crates/libsy/src/core/algorithm.rs +++ b/crates/libsy/src/core/algorithm.rs @@ -813,10 +813,9 @@ pub trait Algorithm: Send + Sync + 'static { #[cfg(test)] mod tests { use super::*; + use crate::text::{completion_text, text_request, text_response}; use futures::StreamExt; - use switchyard_protocol::{ - LlmResponse, LlmResponseChunk, completion_text, text_request, text_response, - }; + use switchyard_protocol::{LlmResponse, LlmResponseChunk}; #[derive(Debug, thiserror::Error)] #[error("{0}")] diff --git a/crates/libsy/src/core/classifier.rs b/crates/libsy/src/core/classifier.rs index ff2dc50f..30078092 100644 --- a/crates/libsy/src/core/classifier.rs +++ b/crates/libsy/src/core/classifier.rs @@ -8,16 +8,16 @@ use switchyard_protocol::{Request, Response}; /// One classifier's recommendation of a routing `target`, with a `[0.0, 1.0]` confidence. #[derive(Debug, Clone, PartialEq)] -pub struct Score { +pub(crate) struct Score { /// `[0.0, 1.0]` confidence in `target`. - pub confidence: f64, + pub(crate) confidence: f64, /// The target (model / tier) being recommended. - pub target: String, + pub(crate) target: String, } /// A classifier's verdict for a request: a set of target [`Score`]s, flagged by how /// confident the classifier is that they are decisive. -pub enum Classification { +pub(crate) enum Classification { /// Definite recommendations; [`argmax`](Self::argmax) always yields the top target. Scores(Vec), /// Recommendations the classifier considers ambiguous; [`argmax`](Self::argmax) yields @@ -31,7 +31,7 @@ impl Classification { /// An [`Ambiguous`](Self::Ambiguous) classification also yields `None` unless /// `ignore_ambiguous` is set, in which case it falls back to the plain argmax. /// Errors if any confidence is `NaN` (an unorderable score the caller should surface). - pub fn argmax(&self, ignore_ambiguous: bool) -> Result> { + pub(crate) fn argmax(&self, ignore_ambiguous: bool) -> Result> { match self { Classification::Scores(scores) => argmax(scores), Classification::Ambiguous(scores) => { @@ -70,7 +70,7 @@ fn argmax(scores: &[Score]) -> Result> { /// Scores targets from the current request and the composition's state. #[async_trait] -pub trait Classifier: Send + Sync { +pub(crate) trait Classifier: Send + Sync { /// Stable tier represented by `selected_model`, when this classifier defines one. fn routing_tier(&self, _selected_model: &str) -> Option<&'static str> { None @@ -97,7 +97,7 @@ pub trait Classifier: Send + Sync { #[cfg(test)] mod tests { use super::*; - use switchyard_protocol::text_request; + use crate::text::text_request; /// Terse `Score` builder for the assertions below. fn score(target: &str, confidence: f64) -> Score { diff --git a/crates/libsy/src/core/processor.rs b/crates/libsy/src/core/processor.rs index 74193cb1..8a877d38 100644 --- a/crates/libsy/src/core/processor.rs +++ b/crates/libsy/src/core/processor.rs @@ -10,7 +10,8 @@ use switchyard_protocol::{AggLlmResponse, Decision, Request, Signals}; /// Request-bearing variants ([`Event::Request`], [`Event::Decision`]) borrow the request /// mutably, so a processor may rewrite it in place and the edit propagates to the rest of /// the chain and to the model call. The observation-only variants stay immutable. -pub enum Event<'a> { +#[allow(dead_code)] +pub(crate) enum Event<'a> { /// The inbound request that begins a turn. Request(&'a mut Request), /// An out-of-band agentic-stack signal (tool results, budget updates, …). @@ -31,7 +32,7 @@ pub enum Event<'a> { /// Collects events as the algorithm runs and mutates the composition's state. #[async_trait] -pub trait Processor: Send + Sync { +pub(crate) trait Processor: Send + Sync { /// Process an event, accumulating facts into `state`. Request-bearing events /// ([`Event::Request`], [`Event::Decision`]) may also be rewritten in place. async fn process(&self, state: &mut S, event: Event<'_>) -> Result<()>; @@ -40,8 +41,8 @@ pub trait Processor: Send + Sync { #[cfg(test)] mod tests { use super::*; + use crate::text::{text_request, text_response}; use std::collections::HashMap; - use switchyard_protocol::{text_request, text_response}; type TestState = HashMap<&'static str, u32>; diff --git a/crates/libsy/src/core/state.rs b/crates/libsy/src/core/state.rs index 01ccb08a..7eed11fc 100644 --- a/crates/libsy/src/core/state.rs +++ b/crates/libsy/src/core/state.rs @@ -9,7 +9,8 @@ use crate::algorithms::util::tool_signals::ToolSignals; /// A value in a session's [`State`]. #[derive(Debug, Clone)] -pub enum StateValue { +#[allow(dead_code)] +pub(crate) enum StateValue { /// Text value. String(String), /// Nonnegative counter. @@ -22,13 +23,14 @@ pub enum StateValue { /// Routing facts accumulated across one session's algorithm runs. #[derive(Debug, Clone, Default)] -pub struct State { +#[allow(dead_code)] +pub(crate) struct State { /// Number of turns processed for this session. - pub turn_count: u32, + pub(crate) turn_count: u32, /// Tool-result signals for the current request, set by the tool-signal /// processor. `None` until it runs or when the request has no tool activity, /// so routers must treat absence as "no signal yet". - pub tool_signals: Option, + pub(crate) tool_signals: Option, /// Algorithm-specific state keyed by stable internal names. - pub extra: HashMap, + pub(crate) extra: HashMap, } diff --git a/crates/libsy/src/lib.rs b/crates/libsy/src/lib.rs index 37f6cb22..2c94c572 100644 --- a/crates/libsy/src/lib.rs +++ b/crates/libsy/src/lib.rs @@ -9,9 +9,6 @@ pub use core::algorithm::{ Algorithm, CallLlmRequest, Driver, LlmCallObservation, LlmTarget, LlmTargetSet, RoutedRequest, RunObservation, RunObserver, Step, StepStream, }; -pub use core::classifier::{Classification, Classifier, Score}; -pub use core::processor::{Event, Processor}; -pub use core::state::{State, StateValue}; mod error; pub use error::{DriverError, LibsyError, Result}; @@ -21,9 +18,9 @@ pub use algorithms::llm_class::{ CustomClassifierConfig, CustomClassifierPolicy, LlmClassifierConfig, LlmTaskClassifier, TaskClassifierConfig, }; -pub use algorithms::noop::{Noop, NoopDecision}; -pub use algorithms::passthrough::{Passthrough, PassthroughDecision}; -pub use algorithms::rand::{Random, RandomClassifier, RandomDecision}; +pub use algorithms::noop::Noop; +pub use algorithms::passthrough::Passthrough; +pub use algorithms::rand::{Random, RandomClassifier}; pub use algorithms::stage::{LlmFallback, StageRouter, StageRouterConfig}; pub use algorithms::util::affinity::AffinityRouter; pub use algorithms::util::classifier_contract::ClassifierContractConfig; @@ -41,6 +38,7 @@ pub use algorithms::util::stage::{ }; mod observability; +pub(crate) mod text; /// Registers process-wide compatibility gauges with the global meter provider. /// diff --git a/crates/libsy/src/text.rs b/crates/libsy/src/text.rs new file mode 100644 index 00000000..dda5c4b8 --- /dev/null +++ b/crates/libsy/src/text.rs @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use switchyard_protocol::{AggLlmResponse, ContentBlock}; +#[cfg(test)] +use switchyard_protocol::{LlmRequest, Message, ResponseOutput, Role}; + +#[cfg(test)] +pub(crate) fn text_request(model: Option, prompt: impl Into) -> LlmRequest { + LlmRequest { + model, + messages: vec![Message::text(Role::User, prompt)], + ..LlmRequest::default() + } +} + +#[cfg(test)] +pub(crate) fn text_response( + model: Option, + completion: impl Into, +) -> AggLlmResponse { + AggLlmResponse { + model, + outputs: vec![ResponseOutput { + role: Role::Assistant, + content: vec![ContentBlock::Text { + text: completion.into(), + }], + stop_reason: None, + }], + ..AggLlmResponse::default() + } +} + +pub(crate) fn completion_text(response: &AggLlmResponse) -> String { + response + .outputs + .first() + .map(|output| { + output + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::() + }) + .unwrap_or_default() +} diff --git a/crates/libsy/tests/observability.rs b/crates/libsy/tests/observability.rs index ebea9d7a..31412acd 100644 --- a/crates/libsy/tests/observability.rs +++ b/crates/libsy/tests/observability.rs @@ -37,12 +37,10 @@ use switchyard_libsy::{ Step, TaskClassifierConfig, }; use switchyard_protocol::{ - Context, Decision, LlmResponse, Metadata, Request, Response, RoutedLlmClient, Usage, -}; -use switchyard_protocol::{ - LlmClientError, LlmResponseChunk, LlmResponseStreamEvent, StopReason, text_request, - text_response, + AggLlmResponse, ContentBlock, Context, Decision, LlmRequest, LlmResponse, Message, Metadata, + Request, Response, ResponseOutput, Role, RoutedLlmClient, Usage, }; +use switchyard_protocol::{LlmClientError, LlmResponseChunk, LlmResponseStreamEvent, StopReason}; #[derive(Debug, thiserror::Error)] #[error("{0}")] @@ -52,6 +50,28 @@ fn test_error(message: &'static str) -> LibsyError { LibsyError::external("test", TestError(message)) } +fn text_request(model: Option, prompt: impl Into) -> LlmRequest { + LlmRequest { + model, + messages: vec![Message::text(Role::User, prompt)], + ..LlmRequest::default() + } +} + +fn text_response(model: Option, completion: impl Into) -> AggLlmResponse { + AggLlmResponse { + model, + outputs: vec![ResponseOutput { + role: Role::Assistant, + content: vec![ContentBlock::Text { + text: completion.into(), + }], + stop_reason: None, + }], + ..AggLlmResponse::default() + } +} + /// One captured span: its name, contextual parent span name, and fields /// (creation-time fields merged with later `Span::record` updates). #[derive(Clone, Debug, Default)] diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 2747a12e..66eb0835 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -18,75 +18,58 @@ pub use llm::*; pub use metadata::*; pub use stream::*; -/// Builds a single-turn request: one user message carrying `prompt`, for `model`. -/// -/// This deliberately creates only the common text shape. Construct [`LlmRequest`] -/// directly for instructions, tools, multimodal content, or sampling controls. -pub fn text_request(model: Option, prompt: impl Into) -> LlmRequest { - LlmRequest { - model, - messages: vec![Message::text(Role::User, prompt)], - ..LlmRequest::default() +#[cfg(test)] +mod tests { + use super::*; + + fn text_request(model: Option, prompt: impl Into) -> LlmRequest { + LlmRequest { + model, + messages: vec![Message::text(Role::User, prompt)], + ..LlmRequest::default() + } } -} -/// Returns a lossy text view of all user messages, joined by newlines. -/// -/// Only text and refusal blocks are included. Instructions, tool content, -/// reasoning, and media are omitted. Returns an empty string when no user text exists. -pub fn prompt_text(request: &LlmRequest) -> String { - request - .messages - .iter() - .filter(|message| message.role == Role::User) - .filter_map(|message| message.text_content("\n")) - .collect::>() - .join("\n") -} + fn prompt_text(request: &LlmRequest) -> String { + request + .messages + .iter() + .filter(|message| message.role == Role::User) + .filter_map(|message| message.text_content("\n")) + .collect::>() + .join("\n") + } -/// Builds a single-turn response: one assistant message carrying `completion`, for `model`. -/// -/// Construct [`AggLlmResponse`] directly when usage, tools, reasoning, or multiple -/// output items must be represented. -pub fn text_response(model: Option, completion: impl Into) -> AggLlmResponse { - AggLlmResponse { - model, - outputs: vec![ResponseOutput { - role: Role::Assistant, - content: vec![ContentBlock::Text { - text: completion.into(), + fn text_response(model: Option, completion: impl Into) -> AggLlmResponse { + AggLlmResponse { + model, + outputs: vec![ResponseOutput { + role: Role::Assistant, + content: vec![ContentBlock::Text { + text: completion.into(), + }], + stop_reason: None, }], - stop_reason: None, - }], - ..AggLlmResponse::default() + ..AggLlmResponse::default() + } } -} - -/// Returns a lossy text view of the first assistant output. -/// -/// Only text blocks from the first output are concatenated. Refusals, reasoning, -/// tools, media, and additional outputs are omitted. Returns an empty string when -/// no such text exists. -pub fn completion_text(response: &AggLlmResponse) -> String { - response - .outputs - .first() - .map(|output| { - output - .content - .iter() - .filter_map(|block| match block { - ContentBlock::Text { text } => Some(text.as_str()), - _ => None, - }) - .collect::() - }) - .unwrap_or_default() -} -#[cfg(test)] -mod tests { - use super::*; + fn completion_text(response: &AggLlmResponse) -> String { + response + .outputs + .first() + .map(|output| { + output + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::() + }) + .unwrap_or_default() + } #[test] fn request_round_trips_prompt_text() { diff --git a/crates/protocol/src/metadata.rs b/crates/protocol/src/metadata.rs index d5989e83..45b23483 100644 --- a/crates/protocol/src/metadata.rs +++ b/crates/protocol/src/metadata.rs @@ -7,7 +7,7 @@ //! response. [`Metadata::from_headers`] normalizes host-specific HTTP headers into //! that neutral shape. -use std::{collections::BTreeMap, str::FromStr as _}; +use std::collections::BTreeMap; use crate::WireFormat; @@ -345,21 +345,21 @@ fn header<'a>(headers: &'a http::HeaderMap, key: &str) -> Option<&'a str> { .filter(|s| !s.is_empty()) } -/// Utility to convert a slice of string pairs into an `http::HeaderMap`. -pub fn slice_to_header_map(sl: &[(&str, &str)]) -> http::HeaderMap { - let mut m = http::HeaderMap::with_capacity(sl.len()); - for (k, v) in sl { - m.insert( - http::HeaderName::from_str(k).unwrap(), - (*v).try_into().unwrap(), - ); - } - m -} - #[cfg(test)] mod tests { use super::*; + use std::str::FromStr as _; + + fn slice_to_header_map(sl: &[(&str, &str)]) -> http::HeaderMap { + let mut m = http::HeaderMap::with_capacity(sl.len()); + for (k, v) in sl { + m.insert( + http::HeaderName::from_str(k).unwrap(), + (*v).try_into().unwrap(), + ); + } + m + } /// Header carrying Codex's structured turn metadata as a JSON object. const CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata"; diff --git a/crates/switchyard-translation/src/helpers.rs b/crates/switchyard-translation/src/helpers.rs index 5136825c..16b02342 100644 --- a/crates/switchyard-translation/src/helpers.rs +++ b/crates/switchyard-translation/src/helpers.rs @@ -261,7 +261,7 @@ mod tests { use futures::{Stream, StreamExt, stream}; use serde_json::{Value, json}; use switchyard_protocol::{ - LlmClientError, LlmResponseChunk, LlmResponseStreamEvent, completion_text, + AggLlmResponse, ContentBlock, LlmClientError, LlmResponseChunk, LlmResponseStreamEvent, }; use super::{ @@ -273,6 +273,23 @@ mod tests { // A boxed stream item error, matching the streamed IR contract. type BoxError = Box; + fn completion_text(response: &AggLlmResponse) -> String { + response + .outputs + .first() + .map(|output| { + output + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::() + }) + .unwrap_or_default() + } + // Collects a decoded IR stream, surfacing the first error instead of panicking. fn decode_all( bytes: impl Stream, LlmClientError>> + Send + 'static,