@@ -16,7 +16,9 @@ use std::sync::Arc;
1616use std:: time:: { Duration , Instant } ;
1717
1818use 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+ } ;
2022use switchyard_protocol:: { Decision , LlmClientError , ModelId , Request , Response , RoutedLlmClient } ;
2123
2224use 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 ) ]
203211pub struct ClientRouter {
204- routing : Arc < Routing > ,
212+ inner : Arc < ClientRouterInner > ,
213+ }
214+
215+ struct ClientRouterInner {
216+ routing : Routing ,
217+ prompt_processor : SystemPromptProcessor ,
205218}
206219
207220enum 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+
214245impl 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
0 commit comments