Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 13 additions & 8 deletions crates/libsy-examples/examples/research_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

//! Minimal research agent using the [`Algorithm::run`] convenience.
//!
//! Every target owns an `LlmClient`, so the agent runs each request to completion with
//! Every target owns an `RoutedLlmClient`, so the agent runs each request to completion with
//! [`Algorithm::run`]: it serves each offloaded call with the routed
//! target's `default_client` and returns the final response — no stream to drive. The
//! multi-step routing (classify -> route) happens inside the classifier algorithm; the
Expand All @@ -16,8 +16,8 @@ use std::sync::Arc;

use async_trait::async_trait;
use libsy::{
Algorithm, Context, LlmClient, LlmResponse, LlmTarget, LlmTargetSet, Request, Response,
RoutedRequest,
Algorithm, Context, Decision, LlmResponse, LlmTarget, LlmTargetSet, Request, Response,
RoutedLlmClient,
};
use libsy_examples::llm_class::LlmClassifierOrchAlgo;
use switchyard_protocol::{completion_text, text_request, text_response};
Expand All @@ -26,14 +26,19 @@ const CLASSIFIER: &str = "classifier/model";
const STRONG: &str = "strong/model";
const WEAK: &str = "weak/model";

/// Stub transport. Real integrators implement `LlmClient` over their own HTTP.
/// Stub transport. Real integrators implement `RoutedLlmClient` over their own HTTP.
Comment thread
nachiketb-nvidia marked this conversation as resolved.
struct StubClient;

#[async_trait]
impl LlmClient for StubClient {
async fn call(&self, routed: RoutedRequest) -> Result<Response, Box<dyn Error + Send + Sync>> {
impl RoutedLlmClient for StubClient {
async fn call(
&self,
_ctx: Context,
_request: Request,
decision: Arc<dyn Decision>,
) -> Result<Response, Box<dyn Error + Send + Sync>> {
// The model to call is the routed decision's selection, not the inbound name.
let model = routed.decision.selected_model().to_string();
let model = decision.selected_model().to_string();
println!(" -> model call: {model}");
// The classifier returns a score; other models return an answer.
let completion = if model == CLASSIFIER {
Expand All @@ -49,7 +54,7 @@ impl LlmClient for StubClient {
}

fn targets() -> LlmTargetSet {
let client = Arc::new(StubClient) as Arc<dyn LlmClient>;
let client = Arc::new(StubClient) as Arc<dyn RoutedLlmClient>;
let target = |name: &str| LlmTarget {
semantic_name: name.to_string(),
llm_client: Some(client.clone()),
Expand Down
36 changes: 21 additions & 15 deletions crates/libsy-examples/src/ensemble.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ impl Algorithm for EnsembleOrchAlgo {
#[cfg(test)]
mod tests {
use super::*;
use libsy::{LlmClient, LlmResponse, LlmTarget, Response, RoutedRequest, Signals};
use libsy::{LlmResponse, LlmTarget, Response, RoutedLlmClient, Signals};
use std::sync::Mutex as StdMutex;
use switchyard_protocol::text_response;

Expand All @@ -410,18 +410,20 @@ mod tests {
}

#[async_trait]
impl LlmClient for JudgingClient {
impl RoutedLlmClient for JudgingClient {
async fn call(
&self,
routed: RoutedRequest,
_ctx: Context,
request: Request,
decision: Arc<dyn Decision>,
) -> Result<Response, Box<dyn Error + Send + Sync>> {
let name = routed.decision.selected_model().to_string();
let name = decision.selected_model().to_string();
self.calls
.lock()
.map_err(|_| "lock poisoned")?
.push(name.clone());
let completion = if name == self.judge_model {
judge_pick(&prompt_text(&routed.request.llm_request), &self.prefer)
judge_pick(&prompt_text(&request.llm_request), &self.prefer)
} else {
format!("answer from {name}")
};
Expand Down Expand Up @@ -463,7 +465,7 @@ mod tests {
judge_model: judge.to_string(),
prefer: prefer.to_string(),
calls: Arc::clone(&calls),
}) as Arc<dyn LlmClient>;
}) as Arc<dyn RoutedLlmClient>;
let target = |name: &str| LlmTarget {
semantic_name: name.to_string(),
llm_client: Some(client.clone()),
Expand All @@ -485,7 +487,7 @@ mod tests {
candidates: &[&str],
judge: &str,
exploration_turns: u64,
client: Arc<dyn LlmClient>,
client: Arc<dyn RoutedLlmClient>,
) -> EnsembleOrchAlgo {
let target = |name: &str| LlmTarget {
semantic_name: name.to_string(),
Expand Down Expand Up @@ -653,15 +655,17 @@ mod tests {
/// Client whose every call fails.
struct FailingClient;
#[async_trait]
impl LlmClient for FailingClient {
impl RoutedLlmClient for FailingClient {
async fn call(
&self,
_routed: RoutedRequest,
_ctx: Context,
_request: Request,
_decision: Arc<dyn Decision>,
) -> Result<Response, Box<dyn Error + Send + Sync>> {
Err("upstream down".into())
}
}
let client = Arc::new(FailingClient) as Arc<dyn LlmClient>;
let client = Arc::new(FailingClient) as Arc<dyn RoutedLlmClient>;
let target = |name: &str| LlmTarget {
semantic_name: name.to_string(),
llm_client: Some(client.clone()),
Expand Down Expand Up @@ -718,15 +722,17 @@ mod tests {
}

#[async_trait]
impl LlmClient for BarrierClient {
impl RoutedLlmClient for BarrierClient {
async fn call(
&self,
routed: RoutedRequest,
_ctx: Context,
request: Request,
decision: Arc<dyn Decision>,
) -> Result<Response, Box<dyn Error + Send + Sync>> {
let name = routed.decision.selected_model().to_string();
let name = decision.selected_model().to_string();
let completion = if name == self.judge_model {
// Judge runs after the barrier releases; it must not wait.
judge_pick(&prompt_text(&routed.request.llm_request), &self.prefer)
judge_pick(&prompt_text(&request.llm_request), &self.prefer)
} else {
// Hold every candidate call until all sessions have fanned out.
self.barrier.wait().await;
Expand All @@ -746,7 +752,7 @@ mod tests {
barrier: barrier.clone(),
judge_model: "judge/haiku".to_string(),
prefer: "a/model".to_string(),
}) as Arc<dyn LlmClient>;
}) as Arc<dyn RoutedLlmClient>;

// Two independent sessions: separate algo instances, each with its own
// per-session state, sharing only the backend client.
Expand Down
17 changes: 8 additions & 9 deletions crates/libsy-examples/src/llm_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ impl Algorithm for LlmClassifierOrchAlgo {
#[cfg(test)]
mod tests {
use super::*;
use libsy::{LlmClient, LlmResponse, LlmTarget, Response, RoutedRequest};
use libsy::{LlmResponse, LlmTarget, Response, RoutedLlmClient};
use std::sync::Mutex;
use switchyard_protocol::text_response;

Expand All @@ -189,21 +189,20 @@ mod tests {
}

#[async_trait]
impl LlmClient for ScoringClient {
impl RoutedLlmClient for ScoringClient {
async fn call(
&self,
routed: RoutedRequest,
_ctx: Context,
request: Request,
decision: Arc<dyn Decision>,
) -> Result<Response, Box<dyn Error + Send + Sync>> {
let name = routed.decision.selected_model().to_string();
let name = decision.selected_model().to_string();
let completion = if name == self.classifier_model {
self.score.clone()
} else {
format!("answer from {name}")
};
self.seen
.lock()
.map_err(|_| "lock poisoned")?
.push(routed.request);
self.seen.lock().map_err(|_| "lock poisoned")?.push(request);
Ok(Response {
llm_response: LlmResponse::Agg(text_response(None, completion)),
metadata: None,
Expand All @@ -218,7 +217,7 @@ mod tests {
classifier_model: "router/classifier".to_string(),
score: score.to_string(),
seen: Arc::clone(&seen),
}) as Arc<dyn LlmClient>;
}) as Arc<dyn RoutedLlmClient>;
let target = |name: &str| LlmTarget {
semantic_name: name.to_string(),
llm_client: Some(client.clone()),
Expand Down
1 change: 1 addition & 0 deletions crates/libsy-protocol/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ keywords = ["llm", "protocol", "openai", "anthropic"]
publish = ["crates-io"]

[dependencies]
async-trait = "0.1"
futures = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
53 changes: 53 additions & 0 deletions crates/libsy-protocol/src/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! The routed-call server trait and the routing decision it carries.
//!
//! [`RoutedLlmClient`] is the one piece of I/O the protocol does not own: a host
//! implements it to actually perform a model call. [`Decision`] is the routing
//! decision that produced the call, carried alongside so the client and any
//! observer can see which model was chosen and why. Both live here — rather than
//! in libsy's orchestration crate — so a client crate that depends only on the
//! protocol can serve routed calls without pulling in the orchestrator.

use std::error::Error;
use std::sync::Arc;

use async_trait::async_trait;

use crate::{Context, Request, Response};

/// A decision/trace object produced by an algorithm.
///
/// Carried as a trait object (not a generic parameter) so a stream consumer can
/// inspect any algorithm's decision through this common interface without
/// knowing the concrete type. `as_any` is the escape hatch for a consumer that
/// *does* know the algo and wants to downcast to the concrete decision.
pub trait Decision: Send + Sync {
/// The model this decision selected (e.g. the routed target's name).
fn selected_model(&self) -> &str;
/// A human-readable explanation of the decision, for logs and traces.
fn reasoning(&self) -> Option<&str>;
/// Downcast handle: a consumer that knows the algorithm can recover the
/// concrete decision type via `as_any().downcast_ref::<ConcreteDecision>()`.
fn as_any(&self) -> &dyn std::any::Any;
}

/// Performs the actual model call for a target. This is the one piece of I/O the
/// library does not own — a host implements it over its own transport (HTTP SDK,
/// in-process model, mock). It serves a call the stream consumer chose not to
/// override, reached as a routed request's `default_client`.
#[async_trait]
pub trait RoutedLlmClient: Send + Sync {
/// Serve the call, returning the model's response. Call the model named by
/// [`decision.selected_model()`](Decision::selected_model) — the target the algorithm
/// routed to — mapping it to whatever provider model id this client hits.
/// `request.llm_request.model` is the agent's original name, carried through for
/// reference, not a call target. `ctx` carries the request's cross-cutting state.
async fn call(
&self,
ctx: Context,
request: Request,
decision: Arc<dyn Decision>,
Comment thread
nachiketb-nvidia marked this conversation as resolved.
) -> Result<Response, Box<dyn Error + Send + Sync>>;
}
9 changes: 7 additions & 2 deletions crates/libsy-protocol/src/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
//! The request/response envelope: the normalized [`LlmRequest`]/[`LlmResponse`] paired
//! with the original provider payload and correlation [`Metadata`].

use crate::{LlmRequest, LlmResponse};
use crate::{LlmRequest, LlmResponse, WireFormat};
use std::collections::HashMap;

/// Per-request state threaded to an algorithm alongside a request. A placeholder
/// for cross-cutting state (correlation ids, budgets, deadlines) an algorithm will
/// read; empty today.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Context {}
pub struct Context {
pub values: HashMap<String, String>,
}
Comment thread
messiaen marked this conversation as resolved.

/// Correlation and routing metadata attached to a request or response.
///
Expand All @@ -31,6 +34,8 @@ pub struct Metadata {
pub extra_metadata: Option<std::collections::BTreeMap<String, String>>,
/// HTTP headers to attach when forwarding the request/response, if any.
pub http_headers: Option<std::collections::BTreeMap<String, String>>,
/// The wire format the request/response was originally encoded in, if known.
pub wire_format: Option<WireFormat>,
}

/// A request an algorithm routes: the normalized [`LlmRequest`] plus the original
Expand Down
2 changes: 2 additions & 0 deletions crates/libsy-protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@
//!
//! [`Algorithm`]: https://docs.rs/libsy

pub mod client;
pub mod envelope;
pub mod format;
pub mod llm;
pub mod stream;

pub use client::*;
pub use envelope::*;
pub use format::*;
pub use llm::*;
Expand Down
26 changes: 13 additions & 13 deletions crates/libsy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ so it drops into a proxy, gateway, or agent runtime.
Build a target set, pick an algorithm, run a request:

```rust
use libsy::{Algorithm, Context, LlmClient, LlmTarget, LlmTargetSet, Request};
use libsy::{Algorithm, Context, RoutedLlmClient, LlmTarget, LlmTargetSet, Request};
use libsy_examples::llm_class::LlmClassifierOrchAlgo;
use switchyard_protocol::{completion_text, text_request};
use std::sync::Arc;

// Targets the algorithm routes among, each backed by your LlmClient (see below).
let client = Arc::new(MyClient { /* .. */ }) as Arc<dyn LlmClient>;
// Targets the algorithm routes among, each backed by your RoutedLlmClient (see below).
let client = Arc::new(MyClient { /* .. */ }) as Arc<dyn RoutedLlmClient>;
let target = |name: &str| LlmTarget { semantic_name: name.into(), llm_client: Some(client.clone()) };

let algo: Arc<dyn Algorithm> = Arc::new(LlmClassifierOrchAlgo::new(
Expand Down Expand Up @@ -75,19 +75,19 @@ fidelity.

## Targets and clients

An `LlmTarget` pairs a routing `semantic_name` with an optional `LlmClient`. Mapping that
name to a provider model id is the client's job, not the algorithm's — `LlmClient` is
An `LlmTarget` pairs a routing `semantic_name` with an optional `RoutedLlmClient`. Mapping that
name to a provider model id is the client's job, not the algorithm's — `RoutedLlmClient` is
meant to be implemented by you.

```rust
struct MyClient { /* http client, base url, key */ }

#[async_trait::async_trait]
impl LlmClient for MyClient {
async fn call(&self, routed: RoutedRequest)
impl RoutedLlmClient for MyClient {
async fn call(&self, ctx: Context, request: Request, decision: Arc<dyn Decision>)
-> Result<Response, Box<dyn std::error::Error + Send + Sync>> {
let model = routed.decision.selected_model(); // the routed target — map it to a provider id
// routed.request.llm_request.model is the agent's original name (not a call target)
let model = decision.selected_model(); // the routed target — map it to a provider id
// request.llm_request.model is the agent's original name (not a call target)
// ... POST to your endpoint, read the completion ...
let completion = String::from("provider response text");
Ok(Response {
Expand All @@ -104,9 +104,9 @@ To stream instead, return `LlmResponse::Stream(..)` — a boxed
`Stream<Item = Result<LlmResponseChunk, _>>` — and emit chunks as they arrive from your
upstream. See [Streaming responses](#streaming-responses) below.

A `RoutedRequest` bundles the `request` with the routing `decision` and the target's
`default_client`; the model to call is `decision.selected_model()`, never a mutated
request field. `semantic_name` is the label an algorithm routes by; the client maps it to
A `RoutedRequest` bundles the `request` with the routing `decision`, the target's
`default_client`, and the request's `ctx`; the model to call is
`decision.selected_model()`, never a mutated request field. `semantic_name` is the label an algorithm routes by; the client maps it to
the id it calls — they can differ (`"strong"` → `"openai/gpt-4o"`) or coincide.

## Running a request
Expand All @@ -129,7 +129,7 @@ so pulling it paces the algorithm; each run is independent, so many run concurre
## Streaming responses

A `Response` carries an `LlmResponse` that is either buffered (`Agg`) or a live token
stream (`Stream`). An `LlmClient` — or an algorithm — chooses: return
stream (`Stream`). An `RoutedLlmClient` — or an algorithm — chooses: return
`LlmResponse::Agg(..)` for a whole answer, or `LlmResponse::Stream(..)` to forward tokens
as they arrive. libsy never buffers a stream on your behalf; it flows through the algorithm
untouched, so `run` / `run_stream` hand back whatever was produced and **the caller
Expand Down
Loading
Loading