Skip to content

Commit 1e5d954

Browse files
committed
feat: configure system prompts by target
Signed-off-by: Alex Fournier <afournier@nvidia.com>
1 parent a17efa9 commit 1e5d954

16 files changed

Lines changed: 1100 additions & 94 deletions

File tree

crates/libsy-llm-client/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
//! [`switchyard_libsy::Algorithm::run_stream`] and serves every model call the algorithm
1616
//! offloads, so a host that just wants the answer does not have to drive the step stream
1717
//! itself.
18+
//! A host that drives the stream itself can use [`ClientRouter::resolve_call`] to resolve the
19+
//! selected client and apply target-specific prompts before making each call.
1820
1921
pub mod backend;
2022
pub mod client;
@@ -30,5 +32,5 @@ pub use client::{ModelConfig, TranslatingLlmClient};
3032
pub use error::{LlmClientError, Result};
3133
pub use observation::{LlmCallObservation, RunObservation, RunObserver};
3234
pub use raw::RawResponse;
33-
pub use run::{ClientRouter, run};
35+
pub use run::{ClientRouter, ResolvedLlmCall, run};
3436
pub use switchyard_translation::RawEventStream;

crates/libsy-llm-client/src/run.rs

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ use std::sync::Arc;
1616
use std::time::{Duration, Instant};
1717

1818
use parking_lot::Mutex;
19-
use switchyard_libsy::{Algorithm, CallModel, LibsyError, Result, drive};
19+
use switchyard_libsy::{
20+
Algorithm, CallModel, LibsyError, Result, SystemPromptProcessor, TargetPrompts, drive,
21+
};
2022
use switchyard_protocol::{Decision, LlmClientError, ModelId, Request, Response, RoutedLlmClient};
2123

2224
use crate::observation::{LlmCallObservation, RunObservation, RunObserver};
@@ -146,7 +148,6 @@ async fn serve(
146148
routed_calls: Arc<Mutex<RoutedCallWindows>>,
147149
) -> Result<()> {
148150
let span = tracing::Span::current();
149-
observability::record_gen_ai_request(&span, &call.request.llm_request);
150151
if let Some(session_id) = call
151152
.request
152153
.metadata
@@ -156,14 +157,21 @@ async fn serve(
156157
span.record("gen_ai.conversation.id", session_id);
157158
}
158159
let target = ModelId::from(call.selected_model_id());
159-
let request = call.request.clone();
160160
let is_answer_call = call.decision.is_answer_call();
161161
// Resolved before the clock starts: picking the client is Switchyard's work, not
162162
// the provider's, so it belongs in the routing overhead.
163-
let client = clients.route(&target);
163+
let resolved = clients.resolve_call(&call.decision, call.request.clone());
164+
observability::record_gen_ai_request(
165+
&span,
166+
&resolved
167+
.as_ref()
168+
.map(ResolvedLlmCall::request)
169+
.unwrap_or(&call.request)
170+
.llm_request,
171+
);
164172
let started = Instant::now();
165-
let result = match client {
166-
Ok(client) => client.call(request).await,
173+
let result = match resolved {
174+
Ok(call) => call.call().await,
167175
Err(error) => Err(error),
168176
}
169177
.map_err(|source| LibsyError::client_call(target.clone(), source));
@@ -201,7 +209,12 @@ async fn serve(
201209
/// Cloning is cheap — the mapping is shared, so one router can serve every request.
202210
#[derive(Clone)]
203211
pub struct ClientRouter {
204-
routing: Arc<Routing>,
212+
inner: Arc<ClientRouterInner>,
213+
}
214+
215+
struct ClientRouterInner {
216+
routing: Routing,
217+
prompt_processor: SystemPromptProcessor,
205218
}
206219

207220
enum Routing {
@@ -211,11 +224,40 @@ enum Routing {
211224
ByModel(HashMap<ModelId, Arc<dyn RoutedLlmClient>>),
212225
}
213226

227+
/// One normalized model call resolved to its target client and target-specific prompt.
228+
pub struct ResolvedLlmCall {
229+
client: Arc<dyn RoutedLlmClient>,
230+
request: Request,
231+
}
232+
233+
impl ResolvedLlmCall {
234+
/// The request that will be sent, including any selected target's system prompt.
235+
pub fn request(&self) -> &Request {
236+
&self.request
237+
}
238+
239+
/// Perform the resolved call through its selected client.
240+
pub async fn call(self) -> std::result::Result<Response, LlmClientError> {
241+
self.client.call(self.request).await
242+
}
243+
}
244+
214245
impl ClientRouter {
215246
/// Build a router over `model name -> client`, for targets spread across providers.
216247
pub fn new(by_model: HashMap<ModelId, Arc<dyn RoutedLlmClient>>) -> Self {
248+
Self::new_with_target_prompts(by_model, TargetPrompts::default())
249+
}
250+
251+
/// Build a router with system prompts applied to answer calls by selected target.
252+
pub fn new_with_target_prompts(
253+
by_model: HashMap<ModelId, Arc<dyn RoutedLlmClient>>,
254+
prompts: TargetPrompts,
255+
) -> Self {
217256
Self {
218-
routing: Arc::new(Routing::ByModel(by_model)),
257+
inner: Arc::new(ClientRouterInner {
258+
routing: Routing::ByModel(by_model),
259+
prompt_processor: SystemPromptProcessor::new(prompts),
260+
}),
219261
}
220262
}
221263

@@ -225,20 +267,51 @@ impl ClientRouter {
225267
/// backends internally and rejects ones it does not know, so enumerating them here would
226268
/// only duplicate that.
227269
pub fn single(client: Arc<dyn RoutedLlmClient>) -> Self {
270+
Self::single_with_target_prompts(client, TargetPrompts::default())
271+
}
272+
273+
/// A single-client router with system prompts applied by selected target.
274+
pub fn single_with_target_prompts(
275+
client: Arc<dyn RoutedLlmClient>,
276+
prompts: TargetPrompts,
277+
) -> Self {
228278
Self {
229-
routing: Arc::new(Routing::Single(client)),
279+
inner: Arc::new(ClientRouterInner {
280+
routing: Routing::Single(client),
281+
prompt_processor: SystemPromptProcessor::new(prompts),
282+
}),
283+
}
284+
}
285+
286+
/// Resolve a decision and request into the exact target call a host should perform.
287+
///
288+
/// This is the prompt-aware host boundary. It stamps the selected model and prepends
289+
/// that target's configured prompt only for answer calls.
290+
pub fn resolve_call(
291+
&self,
292+
decision: &Decision,
293+
mut request: Request,
294+
) -> std::result::Result<ResolvedLlmCall, LlmClientError> {
295+
let target = decision.selected_model_id();
296+
let client = Arc::clone(self.route(target)?);
297+
request.llm_request.model = Some(target.to_string());
298+
if let Some(prompt) = self.inner.prompt_processor.prompt_for(decision) {
299+
switchyard_translation::prepend_system_prompt(&mut request.llm_request, prompt);
230300
}
301+
Ok(ResolvedLlmCall { client, request })
231302
}
232303

233-
/// The client that serves `model`.
304+
/// The unmodified client that serves `model`.
234305
///
235306
/// Errors with [`LlmClientError::Configuration`] when the router maps models and has no
236-
/// entry for this one, rather than silently sending the call to another provider.
307+
/// entry for this one, rather than silently sending the call to another provider. This
308+
/// lookup does not apply target prompts; hosts serving decisions should use
309+
/// [`resolve_call`](Self::resolve_call).
237310
pub fn route(
238311
&self,
239312
model: &ModelId,
240313
) -> std::result::Result<&Arc<dyn RoutedLlmClient>, LlmClientError> {
241-
match self.routing.as_ref() {
314+
match &self.inner.routing {
242315
Routing::Single(client) => Ok(client),
243316
Routing::ByModel(by_model) => {
244317
by_model
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
use std::sync::Arc;
5+
6+
use async_trait::async_trait;
7+
use parking_lot::Mutex;
8+
use switchyard_libsy::{Algorithm, Driver, Random, TargetPrompts};
9+
use switchyard_llm_client::ClientRouter;
10+
use switchyard_protocol::{
11+
ContentBlock, Decision, FormatId, InstructionBlock, LlmClientError, LlmResponse, ModelId,
12+
PreservationMetadata, Request, Response, Role, RoutedLlmClient, text_request, text_response,
13+
};
14+
15+
const JUDGE: &str = "judge/model";
16+
const WEAK: &str = "weak/model";
17+
const STRONG: &str = "strong/model";
18+
19+
#[derive(Default)]
20+
struct RecordingClient {
21+
calls: Mutex<Vec<Request>>,
22+
overflow: Option<ModelId>,
23+
}
24+
25+
#[async_trait]
26+
impl RoutedLlmClient for RecordingClient {
27+
async fn call(&self, request: Request) -> Result<Response, LlmClientError> {
28+
let model = request.model_id().unwrap_or_default();
29+
self.calls.lock().push(request);
30+
if self.overflow.as_ref() == Some(&model) {
31+
return Err(LlmClientError::ContextWindowExceeded {
32+
model,
33+
message: "too long".to_string(),
34+
});
35+
}
36+
Ok(Response {
37+
llm_response: LlmResponse::Agg(text_response(Some(model.to_string()), "ok")),
38+
metadata: None,
39+
})
40+
}
41+
}
42+
43+
struct JudgeThenAnswer;
44+
45+
#[async_trait]
46+
impl Algorithm for JudgeThenAnswer {
47+
fn name(&self) -> &str {
48+
"judge_then_answer"
49+
}
50+
51+
async fn route(
52+
self: Arc<Self>,
53+
driver: Driver,
54+
request: Request,
55+
) -> switchyard_libsy::Result<Response> {
56+
driver
57+
.call_model(request.clone(), Decision::new(JUDGE, false))
58+
.await?;
59+
let decision = Decision::new(STRONG, true);
60+
driver.decide(decision.clone()).await?;
61+
driver.call_model(request, decision).await
62+
}
63+
}
64+
65+
fn request() -> Request {
66+
let mut preservation = PreservationMetadata::default();
67+
preservation.requests.insert(
68+
"openai_chat".into(),
69+
serde_json::json!({
70+
"model": "switchyard/test",
71+
"messages": [
72+
{"role": "system", "content": "client prompt"},
73+
{"role": "user", "content": "hi"}
74+
]
75+
}),
76+
);
77+
let mut llm_request = text_request(Some("switchyard/test".to_string()), "hi");
78+
llm_request.instructions.push(InstructionBlock {
79+
role: Role::System,
80+
content: vec![ContentBlock::Text {
81+
text: "client prompt".to_string(),
82+
}],
83+
});
84+
llm_request.preservation = preservation;
85+
Request {
86+
llm_request,
87+
raw_request: None,
88+
metadata: None,
89+
}
90+
}
91+
92+
fn client_router(client: Arc<dyn RoutedLlmClient>, prompts: TargetPrompts) -> ClientRouter {
93+
ClientRouter::single_with_target_prompts(client, prompts)
94+
}
95+
96+
fn instruction_text(request: &Request) -> Vec<&str> {
97+
request
98+
.llm_request
99+
.instructions
100+
.iter()
101+
.flat_map(|instruction| instruction.content.iter())
102+
.filter_map(|block| match block {
103+
ContentBlock::Text { text } => Some(text.as_str()),
104+
_ => None,
105+
})
106+
.collect()
107+
}
108+
109+
#[tokio::test]
110+
async fn target_prompts_apply_only_to_answer_calls() -> switchyard_libsy::Result<()> {
111+
let client = Arc::new(RecordingClient::default());
112+
let routed_client: Arc<dyn RoutedLlmClient> = client.clone();
113+
let prompts = TargetPrompts::default()
114+
.with(JUDGE, "judge prompt must not be applied")
115+
.with(STRONG, "strong prompt");
116+
117+
switchyard_llm_client::run(
118+
Arc::new(JudgeThenAnswer),
119+
client_router(routed_client, prompts),
120+
request(),
121+
None,
122+
)
123+
.await?;
124+
125+
let calls = client.calls.lock();
126+
assert_eq!(calls.len(), 2);
127+
assert_eq!(instruction_text(&calls[0]), ["client prompt"]);
128+
assert!(!calls[0].llm_request.preservation.requests.is_empty());
129+
assert_eq!(
130+
instruction_text(&calls[1]),
131+
["strong prompt", "client prompt"]
132+
);
133+
assert_eq!(
134+
calls[1].llm_request.preservation.requests[&FormatId::from("openai_chat")]["messages"][0],
135+
serde_json::json!({"role": "system", "content": "strong prompt"})
136+
);
137+
Ok(())
138+
}
139+
140+
#[tokio::test]
141+
async fn fallback_call_receives_the_new_targets_prompt() -> switchyard_libsy::Result<()> {
142+
let client = Arc::new(RecordingClient {
143+
calls: Mutex::new(Vec::new()),
144+
overflow: Some(ModelId::from(WEAK)),
145+
});
146+
let routed_client: Arc<dyn RoutedLlmClient> = client.clone();
147+
let prompts = TargetPrompts::default()
148+
.with(WEAK, "weak prompt")
149+
.with(STRONG, "strong prompt");
150+
let algorithm = Random::new(
151+
vec![ModelId::from(WEAK), ModelId::from(STRONG)],
152+
Some(vec![1.0, 0.0]),
153+
Some(1),
154+
)?;
155+
156+
switchyard_llm_client::run(
157+
Arc::new(algorithm),
158+
client_router(routed_client, prompts),
159+
request(),
160+
None,
161+
)
162+
.await?;
163+
164+
let calls = client.calls.lock();
165+
assert_eq!(calls.len(), 2);
166+
assert_eq!(calls[0].model_id().as_deref(), Some(WEAK));
167+
assert_eq!(
168+
instruction_text(&calls[0]),
169+
["weak prompt", "client prompt"]
170+
);
171+
assert_eq!(calls[1].model_id().as_deref(), Some(STRONG));
172+
assert_eq!(
173+
instruction_text(&calls[1]),
174+
["strong prompt", "client prompt"]
175+
);
176+
Ok(())
177+
}

0 commit comments

Comments
 (0)