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
4 changes: 3 additions & 1 deletion crates/libsy-llm-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
//! [`switchyard_libsy::Algorithm::run_stream`] and serves every model call the algorithm
//! offloads, so a host that just wants the answer does not have to drive the step stream
//! itself.
//! A host that drives the stream itself can use [`ClientRouter::resolve_call`] to resolve the
//! candidate client and apply target-specific prompts before making each call.

pub mod backend;
pub mod client;
Expand All @@ -30,5 +32,5 @@ pub use client::{ModelConfig, TranslatingLlmClient};
pub use error::{LlmClientError, Result};
pub use observation::{LlmCallObservation, RunObservation, RunObserver};
pub use raw::RawResponse;
pub use run::{ClientRouter, run};
pub use run::{ClientRouter, ResolvedLlmCall, run};
pub use switchyard_translation::RawEventStream;
78 changes: 70 additions & 8 deletions crates/libsy-llm-client/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant};

use parking_lot::Mutex;
use switchyard_libsy::{Algorithm, CallModel, LibsyError, Result, drive};
use switchyard_libsy::{Algorithm, CallModel, LibsyError, Result, TargetPrompts, drive};
use switchyard_protocol::{
Decision, LlmClientError, ModelId, Request, Response, RoutedLlmClient, RoutingFallbackReason,
};
Expand Down Expand Up @@ -207,7 +207,6 @@ async fn call_one(
count: usize,
) -> Result<Response> {
let span = tracing::Span::current();
observability::record_gen_ai_request(&span, &request.llm_request);
if let Some(session_id) = call
.request
.metadata
Expand All @@ -217,9 +216,13 @@ async fn call_one(
span.record("gen_ai.conversation.id", session_id);
}
let is_answer_call = call.is_answer_call;
// Resolved before the clock starts: picking the client is Switchyard's work, not
// the provider's, so it belongs in the routing overhead.
// Client and prompt selection are Switchyard work, so they remain outside provider latency.
let client = clients.route(model_id);
let mut request = request;
if client.is_ok() {
clients.apply_target_prompt(model_id, is_answer_call, &mut request);
}
observability::record_gen_ai_request(&span, &request.llm_request);
let started = Instant::now();
let result = match client {
Ok(client) => client.call(request).await,
Expand Down Expand Up @@ -290,7 +293,12 @@ fn request_for(request: &Request, target: &ModelId) -> Request {
/// Cloning is cheap — the mapping is shared, so one router can serve every request.
#[derive(Clone)]
pub struct ClientRouter {
routing: Arc<Routing>,
inner: Arc<ClientRouterInner>,
}

struct ClientRouterInner {
routing: Routing,
prompts: TargetPrompts,
}

enum Routing {
Expand All @@ -300,11 +308,40 @@ enum Routing {
ByModel(HashMap<ModelId, Arc<dyn RoutedLlmClient>>),
}

/// One normalized model call resolved to its target client and target-specific prompt.
pub struct ResolvedLlmCall {
client: Arc<dyn RoutedLlmClient>,
request: Request,
}

impl ResolvedLlmCall {
/// The request that will be sent, including any selected target's system prompt.
pub fn request(&self) -> &Request {
&self.request
}

/// Perform the resolved call through its selected client.
pub async fn call(self) -> std::result::Result<Response, LlmClientError> {
self.client.call(self.request).await
}
}

impl ClientRouter {
/// Build a router over `model name -> client`, for targets spread across providers.
pub fn new(by_model: HashMap<ModelId, Arc<dyn RoutedLlmClient>>) -> Self {
Self::new_with_target_prompts(by_model, TargetPrompts::default())
}

/// Build a router with system prompts applied to answer calls by selected target.
pub fn new_with_target_prompts(
by_model: HashMap<ModelId, Arc<dyn RoutedLlmClient>>,
prompts: TargetPrompts,
) -> Self {
Self {
routing: Arc::new(Routing::ByModel(by_model)),
inner: Arc::new(ClientRouterInner {
routing: Routing::ByModel(by_model),
prompts,
}),
}
}

Expand All @@ -315,7 +352,32 @@ impl ClientRouter {
/// only duplicate that.
pub fn single(client: Arc<dyn RoutedLlmClient>) -> Self {
Self {
routing: Arc::new(Routing::Single(client)),
inner: Arc::new(ClientRouterInner {
routing: Routing::Single(client),
prompts: TargetPrompts::default(),
}),
}
}

/// Resolve a candidate model and request into the exact target call a host should perform.
///
/// This is the prompt-aware host boundary. It stamps the selected model and prepends
/// that target's configured prompt only for answer calls.
pub fn resolve_call(
&self,
target: &ModelId,
is_answer_call: bool,
mut request: Request,
) -> std::result::Result<ResolvedLlmCall, LlmClientError> {
let client = Arc::clone(self.route(target)?);
request.llm_request.model = Some(target.to_string());
self.apply_target_prompt(target, is_answer_call, &mut request);
Ok(ResolvedLlmCall { client, request })
}

fn apply_target_prompt(&self, target: &ModelId, is_answer_call: bool, request: &mut Request) {
if is_answer_call && let Some(prompt) = self.inner.prompts.get(target) {
switchyard_translation::prepend_system_prompt(&mut request.llm_request, prompt);
}
}

Expand All @@ -327,7 +389,7 @@ impl ClientRouter {
&self,
model: &ModelId,
) -> std::result::Result<&Arc<dyn RoutedLlmClient>, LlmClientError> {
match self.routing.as_ref() {
match &self.inner.routing {
Routing::Single(client) => Ok(client),
Routing::ByModel(by_model) => {
by_model
Expand Down
90 changes: 90 additions & 0 deletions crates/libsy-llm-client/tests/target_prompts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

use std::{collections::HashMap, sync::Arc};

use async_trait::async_trait;
use parking_lot::Mutex;
use switchyard_libsy::{Random, TargetPrompts};
use switchyard_llm_client::ClientRouter;
use switchyard_protocol::{
ContentBlock, LlmClientError, LlmResponse, ModelId, Request, Response, RoutedLlmClient,
text_response,
};

const WEAK: &str = "weak/model";
const STRONG: &str = "strong/model";

#[derive(Default)]
struct RecordingClient {
calls: Mutex<Vec<Request>>,
overflow: Option<ModelId>,
}

#[async_trait]
impl RoutedLlmClient for RecordingClient {
async fn call(&self, request: Request) -> Result<Response, LlmClientError> {
let model = request.model_id().unwrap_or_default();
self.calls.lock().push(request);
if self.overflow.as_ref() == Some(&model) {
return Err(LlmClientError::ContextWindowExceeded {
model,
message: "too long".to_string(),
});
}
Ok(Response {
llm_response: LlmResponse::Agg(text_response(Some(model.to_string()), "ok")),
metadata: None,
})
}
}

fn instruction_text(request: &Request) -> Vec<&str> {
request
.llm_request
.instructions
.iter()
.flat_map(|instruction| instruction.content.iter())
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect()
}

#[tokio::test]
async fn fallback_call_receives_the_new_targets_prompt() -> switchyard_libsy::Result<()> {
let client = Arc::new(RecordingClient {
calls: Mutex::new(Vec::new()),
overflow: Some(ModelId::from(WEAK)),
});
let routed_client: Arc<dyn RoutedLlmClient> = client.clone();
let clients = HashMap::from([
(ModelId::from(WEAK), Arc::clone(&routed_client)),
(ModelId::from(STRONG), routed_client),
]);
let prompts = TargetPrompts::default()
.with(WEAK, "weak prompt")
.with(STRONG, "strong prompt");
let algorithm = Random::new(
vec![ModelId::from(WEAK), ModelId::from(STRONG)],
Some(vec![1.0, 0.0]),
Some(1),
)?;

switchyard_llm_client::run(
Arc::new(algorithm),
ClientRouter::new_with_target_prompts(clients, prompts),
Request::default(),
None,
)
.await?;

let calls = client.calls.lock();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].model_id().as_deref(), Some(WEAK));
assert_eq!(instruction_text(&calls[0]), ["weak prompt"]);
assert_eq!(calls[1].model_id().as_deref(), Some(STRONG));
assert_eq!(instruction_text(&calls[1]), ["strong prompt"]);
Ok(())
}
4 changes: 4 additions & 0 deletions crates/switchyard-server/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@ max_retries = 2
id = "provider/model"
llm_client = "provider"
extra_body = { chat_template_kwargs = { enable_thinking = false } }
system_prompt = "instructions for this model"
```

`extra_body` is target-specific. It shallow-merges top-level provider options into
the outbound request, while explicit request fields win on conflicts.
`system_prompt` is also target-specific. It is prepended only when that target
serves an answer call, including a fallback after another target exceeds its
context window; classifier and judge calls are unchanged.

The `chat_template_kwargs.enable_thinking` example is a provider/model-specific
vLLM option. It is not a portable Switchyard reasoning switch. Use it on a judge
Expand Down
2 changes: 2 additions & 0 deletions crates/switchyard-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ client's `base_url` should receive the caller's login. A forwarding route must
be called through the matching provider API.
Target-level `extra_body` values are shallow-merged into the upstream request when
the request does not already contain that key.
An optional target-level `system_prompt` is prepended when that target serves an
answer call; it is not added to classifier or judge calls.
`max_retries` defaults to `2` and applies to transport failures, timeouts, HTTP 408/429, and 5xx
responses.

Expand Down
Loading