Mechanics of Libsy¶
Libsy expresses work as a stream of Steps. Your application executes that work
with its own clients. Done is the exception: it carries the completed routing
outcome.
Follow one request through that exchange below. The snippets show parts of an integration; the Rust quickstart has a complete program, imports, and matching development-API dependencies.
Why execution stays with your application¶
Libsy separates routing policy from model execution. The algorithm decides which models to use and how their results affect the next step. Your application decides how those models are called.
flowchart TD
route["Libsy algorithm<br/>Choose the next Step"]
execute["Your application<br/>Run your model or decision client"]
done["Your application<br/>Use the routing outcome"]
route -->|"CallModel or CallDecision"| execute
execute -->|"call.respond(...)"| route
route -->|"Done"| done
classDef algorithm fill:#eef6df,stroke:#76b900,color:#24350b
classDef application fill:#f5f6f7,stroke:#6b7280,color:#1f2937
class route algorithm
class execute,done application
A run can finish with Done immediately or request several calls before finishing.
A gateway can fulfill these calls through its existing dispatch, credentials, retries, and observability. That includes calls to judges used during routing. An agent harness can supply its conversation and tool history, then use its model clients to execute the requested work. Both adapt their inputs and outputs to the shared protocol types.
Routing can require more than choosing a model ID. An algorithm may change a
request, ask a judge, inspect an answer, or compare results from several models.
Returning responses through call.respond lets it continue that work. When a
selection alone is enough, Done leaves the final model call to your application.
The shared protocol crate lets clients and algorithms use the same request and response contracts. A client can implement those contracts without depending on the routing algorithm implementation.
Start with a request¶
Your application supplies a
Request.
Its llm_request field holds the
LlmRequest:
the conversation, instructions, tools, and generation settings. These
provider-neutral types come from switchyard-protocol.
let request = Request {
llm_request: LlmRequest {
messages: vec![Message::text(Role::User, "Fix the failing tests.")],
..Default::default()
},
metadata: Some(Metadata {
session_id: Some("session-42".into()),
..Default::default()
}),
..Default::default()
};
Metadata
holds the session ID above, which algorithms can use to retain session decisions.
It can also include a correlation ID for your logs. The optional raw_request
field holds data your application retains; libsy does not read it.
Next, supply the models the algorithm can use.
RuntimeModels
groups model IDs by
Category,
such as Efficient, Capable, or Judge:
let models = Arc::new(RuntimeModels::new(HashMap::from([
(Category::Efficient, vec!["Efficient".into()]),
(Category::Capable, vec!["Capable".into()]),
(Category::Judge, vec!["Judge".into()]),
(Category::Any, vec!["Efficient".into(), "Capable".into()]),
])));
These are local example IDs. Within a category, models are ordered best-first. A
ModelId
names a destination; your application decides which client serves it.
With an algorithm, request, model pool, and initial
State, start a run:
run_stream
returns a
StepStream.
Each item is a Result<Step>: either the next step or an error that ends the run.
Read the next Step¶
A Step describes
one unit of work for your application, or reports that routing has finished.
Its three variants are:
pub enum Step {
CallModel(Box<CallModel>),
CallDecision(Box<CallDecision>),
Done(Box<RoutingOutcome>),
}
When you write Step::CallModel(call) in a match, call is the Box<CallModel>
stored in that variant. It is an object describing a requested operation, with a
way to return its result to the waiting algorithm.
Reuse steps from above to read those operations. Here, client and
decision_client are your async adapters; the next sections explain their inputs
and results:
while let Some(step) = steps.next().await {
match step? {
Step::CallModel(call) => {
let request = call.request.clone();
call.respond(client.call(request)).await?;
}
Step::CallDecision(call) => {
let request = call.request.clone();
call.respond(decision_client.call(request)).await?;
}
Step::Done(outcome) => {
// Inspect the result; final-answer handling is shown below.
println!("Selected: {}", outcome.selected_model_id()?);
break;
}
}
}
This loop serves one call at a time. Concurrent execution keeps reading steps while calls are running.
For example, a signal-only router can finish locally. A classifier might first ask your client to call a judge:
Signal-only routing: Done(outcome)
Judge-based routing: CallModel(call) -> your response -> Done(outcome)
Decision routing: CallDecision(call) -> your typed response -> Done(outcome)
Fulfill a model call¶
Suppose the next step is CallModel. The
CallModel
object describes the operation:
// The model has already been set to the first candidate.
let request = call.request.clone();
let first_candidate = &call.models[0];
assert_eq!(request.model_id().as_ref(), Some(first_candidate));
models is a nonempty candidate list in fallback order. Your application executes
the attempts and must update the request's model before trying a later candidate.
call.algorithm names the requesting algorithm for your own telemetry.
Your application returns the result through
call.respond.
Inside the Step::CallModel(call) arm, the exchange looks like this:
let request = call.request.clone();
// Pass the work future to respond; it returns the result to the algorithm.
call.respond(client.call(request)).await?;
Here, client.call is your async adapter, returning
switchyard_libsy::Result<Response>. It owns the provider call and any format
conversion, as in the OpenRouter adapter.
respond consumes the call, allowing the algorithm to continue with its result.
The returned
Response
contains an
LlmResponse
in its llm_response field:
pub enum LlmResponse {
Stream(LlmResponseStream), // Live response events.
Agg(AggLlmResponse), // A complete, buffered answer.
}
The
LlmResponseStreamEvent
provides access to provider-neutral text, tool-call updates, and usage information.
StepStream carries work and the routing outcome. LlmResponse::Stream carries
model output. Your client can return that live stream without reading it to
completion first.
If you want a shared client interface, switchyard-protocol defines
RoutedLlmClient.
Its async call(Request) returns a Response or LlmClientError; the
host guide shows the error
adapter. Using your own adapter method, as above, also works.
Return a typed decision¶
An algorithm can ask a decision model to evaluate a question through
Step::CallDecision(call). This time, call holds a
CallDecision.
It has one model and a
DecisionRequest
in request.
The algorithm creates the question. Your application sends the supplied request through its decision client and returns the typed answer:
The adapter returns switchyard_libsy::Result<DecisionResponse>. The optional
shared interface is
RoutedDecisionClient.
The algorithm applies its routing policy to the answer. The decision response is
distinct from the final conversational answer.
A decision request contains shared context and named questions. Each
question
declares its expected answer shape: Boolean, choice among options, or score on a
rubric. The client's
DecisionResponse
uses the same question IDs.
Example payloads: a Boolean question and its answer
These values illustrate what crosses the boundary. The algorithm supplies a
request like this in call.request:
let request = DecisionRequest {
model: Some("Judge".into()),
context: serde_json::json!({"task": "Fix the failing tests."}),
questions: BTreeMap::from([(
"needs_capable".into(),
DecisionQuestion {
instructions: serde_json::json!("Does this task need Capable?"),
kind: DecisionKind::Boolean {
true_description: None,
false_description: None,
},
},
)]),
};
The client might return this answer to needs_capable:
Receive the final outcome¶
Once the algorithm has enough information, it emits Step::Done(outcome) with a
RoutingOutcome.
Inside that match arm, check whether routing has already produced an answer:
let response = match outcome.response {
Some(response) => response, // Reuse the answer obtained during routing.
None => client.call(outcome.request).await?, // Make the final call.
};
With Some(response), another final call would repeat work already done. With
None, your application can send outcome.request now, as above, or return it to
a gateway to send later. Signal-only Stage takes this second path.
Use the returned request: it includes algorithm changes and already names the
selected model. outcome.selected_model_ids lists that model first, followed by
ordered fallbacks.
To read the final answer as text, collect a live stream or use the existing buffered answer:
For live text, set stream: true in the initial LlmRequest before starting the
run. Use a streaming-capable adapter; the quickstart's OpenRouter adapter returns
buffered responses. Handle both response forms so a buffered answer is still shown:
use std::io::Write;
match response.llm_response {
LlmResponse::Stream(mut events) => {
while let Some(event) = events.next().await {
for chunk in event?.normalized() {
if let LlmResponseChunk::TextDelta { text, .. } = chunk {
print!("{text}");
std::io::stdout().flush()?;
}
}
}
}
LlmResponse::Agg(answer) => println!("{}", completion_text(&answer)),
}
These are alternative ways to consume a response. The text-only loop omits tool and usage events; handle those chunks too when your application needs them.
outcome.state lets supporting algorithms retain caller-managed state between
turns. See Composite sessions
for how this differs from a session ID.
OutcomeMetadata
identifies the run and algorithm and may include evidence for the decision.
Control how work runs¶
Each call has its own reply channel. Awaiting calls one at a time in the step loop serves them sequentially. To run calls concurrently, your application must keep reading steps while polling their work.
For concurrent execution, use
libsy::drive.
It polls your call handlers concurrently and returns the RoutingOutcome. Your
handler receives
Call::Model or Call::Decision;
drive handles Done itself:
// Borrow the adapters so every call can use them.
let client = &client;
let decision_client = &decision_client;
let outcome = switchyard_libsy::drive(algorithm, request, models, |call| async move {
match call {
Call::Model(call) => {
let request = call.request.clone();
call.respond(client.call(request)).await
}
Call::Decision(call) => {
let request = call.request.clone();
call.respond(decision_client.call(request)).await
}
}
}).await?;
It starts with default state. Use run_stream directly to supply your own state
or execution loop.
If the algorithm stops waiting, respond drops that call's work future. Dropping
the step stream aborts the algorithm task; your application cleans up separately
started work. drive drops its outstanding handlers when the run ends or is dropped.
A model-call failure normally stops the host run when propagated with ?.
call.recover_errors lets the algorithm receive and handle it instead. Decision
failures passed through respond also return to the algorithm. Dropping an
unanswered call gives the waiting algorithm DriverError::ResponseDropped.
Once a response stream has been delivered, its consumer owns its lifetime. Cancelling local work cannot guarantee that a provider stops generation or billing, or undo side effects that have already occurred. The host integration guide covers host cleanup in Rust and Python.
The Rust API reference links the complete crate documentation for these types.