Skip to content

Commit ef73aa0

Browse files
committed
feat(server): configure system prompts by target
Signed-off-by: Alex Fournier <afournier@nvidia.com>
1 parent c597bfd commit ef73aa0

7 files changed

Lines changed: 222 additions & 72 deletions

File tree

crates/switchyard-runner/src/algorithm.rs

Lines changed: 34 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ use libsy::{
1313
ClassifyTrigger, CompositeRouter, CompositeRouterConfig, CustomClassifierConfig,
1414
CustomClassifierPolicy, EscalationJudgeConfig, GateTrigger, HandoffNoteConfig,
1515
LlmClassifierConfig, LlmFallback, LlmTaskClassifier, Noop, Passthrough, PickerMode, Random,
16-
StageRouter, StageRouterConfig, SubagentRouter, SubagentRouterConfig, TargetPrompts,
17-
TaskClassifierConfig,
16+
StageRouter, StageRouterConfig, SubagentRouter, SubagentRouterConfig, TaskClassifierConfig,
1817
};
1918
use serde::Deserialize;
2019
use switchyard_protocol::ModelId;
@@ -378,12 +377,6 @@ pub struct StageTierConfig {
378377
/// Notes handed to a tier when the router switches to it.
379378
#[serde(default)]
380379
pub handoff_notes: Option<HandoffNoteConfig>,
381-
/// System prompt handed to the capable tier.
382-
#[serde(default)]
383-
pub capable_system_prompt: Option<String>,
384-
/// System prompt handed to the efficient tier.
385-
#[serde(default)]
386-
pub efficient_system_prompt: Option<String>,
387380
}
388381

389382
impl StageClassifierConfig {
@@ -513,6 +506,39 @@ impl AlgorithmSpec {
513506
}
514507
names
515508
}
509+
510+
/// Response target and routing-only dependency for routers that answer while routing.
511+
pub(crate) fn routing_response_and_dependency(&self) -> Option<(&str, &str)> {
512+
match self {
513+
Self::LlmClassifier { config, .. }
514+
if matches!(
515+
config.mode.unwrap_or(if config.escalation.is_some() {
516+
ClassifierMode::Escalation
517+
} else {
518+
ClassifierMode::Capability
519+
}),
520+
ClassifierMode::Escalation
521+
) =>
522+
{
523+
Some((
524+
config.weak_target.as_deref()?,
525+
config.classifier_target.as_str(),
526+
))
527+
}
528+
Self::Advisor {
529+
executor_target,
530+
advisor_target,
531+
..
532+
} => Some((executor_target, advisor_target)),
533+
Self::Noop { .. }
534+
| Self::Random { .. }
535+
| Self::Passthrough { .. }
536+
| Self::LlmClassifier { .. }
537+
| Self::StageRouter { .. }
538+
| Self::Composite { .. } => None,
539+
}
540+
}
541+
516542
/// Builds this algorithm after resolving configured target names.
517543
pub fn build(
518544
&self,
@@ -945,8 +971,6 @@ fn build_algorithm(
945971
confidence_threshold,
946972
recent_turn_window,
947973
handoff_notes,
948-
capable_system_prompt,
949-
efficient_system_prompt,
950974
} = tiers;
951975
if matches!(picker, PickerMode::CapableFirst) {
952976
tracing::warn!(
@@ -958,12 +982,6 @@ fn build_algorithm(
958982
let mut config = StageRouterConfig::new(*picker, *confidence_threshold);
959983
config.recent_window = *recent_turn_window;
960984
config.handoff_notes = handoff_notes.clone();
961-
config.tier_prompts = tier_prompts(
962-
&capable,
963-
capable_system_prompt.as_deref(),
964-
&efficient,
965-
efficient_system_prompt.as_deref(),
966-
);
967985
// The judge is called through its own target, so it is not a routing
968986
// destination and stays out of the tier pair.
969987
config.llm_fallback = classifier
@@ -998,12 +1016,6 @@ fn build_algorithm(
9981016
StageRouterConfig::new(PickerMode::EfficientFirst, stage.confidence_threshold);
9991017
stage_config.recent_window = stage.recent_turn_window;
10001018
stage_config.handoff_notes = stage.handoff_notes.clone();
1001-
stage_config.tier_prompts = tier_prompts(
1002-
&capable,
1003-
stage.capable_system_prompt.as_deref(),
1004-
&efficient,
1005-
stage.efficient_system_prompt.as_deref(),
1006-
);
10071019
let config = CompositeRouterConfig {
10081020
judge_target,
10091021
judge: classifier.task_classifier_config(),
@@ -1101,23 +1113,6 @@ fn default_classifier_max_output_tokens() -> u64 {
11011113
TaskClassifierConfig::default().max_output_tokens
11021114
}
11031115

1104-
/// Keys each configured system prompt by the target it belongs to.
1105-
fn tier_prompts(
1106-
capable: &str,
1107-
capable_prompt: Option<&str>,
1108-
efficient: &str,
1109-
efficient_prompt: Option<&str>,
1110-
) -> TargetPrompts {
1111-
let mut prompts = TargetPrompts::default();
1112-
if let Some(prompt) = capable_prompt {
1113-
prompts = prompts.with(capable, prompt);
1114-
}
1115-
if let Some(prompt) = efficient_prompt {
1116-
prompts = prompts.with(efficient, prompt);
1117-
}
1118-
prompts
1119-
}
1120-
11211116
fn resolve_targets<'a>(
11221117
route_name: &str,
11231118
names: impl IntoIterator<Item = &'a str>,

crates/switchyard-runner/src/config.rs

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,60 @@ impl DeploymentConfig {
295295
let client: Arc<dyn RoutedLlmClient> = client.clone();
296296
by_model.insert(target.id.clone(), client);
297297
}
298-
Ok((ClientRouter::new(by_model), caller_auth))
298+
let (target_prompts, routing_answer_target) =
299+
self.build_route_target_prompts(route_name, route)?;
300+
let router =
301+
ClientRouter::new_with_target_prompts(by_model, target_prompts, routing_answer_target);
302+
Ok((router, caller_auth))
303+
}
304+
305+
/// Builds the effective prompt policy for this route's completion targets.
306+
fn build_route_target_prompts(
307+
&self,
308+
route_name: &str,
309+
route: &RouteConfig,
310+
) -> RunnerResult<(HashMap<ModelId, String>, Option<ModelId>)> {
311+
let mut prompts = HashMap::new();
312+
let mut aliases = HashMap::<&ModelId, Option<&str>>::new();
313+
for name in route.algorithm.routing_target_names() {
314+
let target = self.targets.get(name).ok_or_else(|| {
315+
RunnerError::configuration(format!("route references unknown target {name}"))
316+
})?;
317+
let prompt = target.system_prompt.as_deref();
318+
if aliases
319+
.insert(&target.id, prompt)
320+
.is_some_and(|configured| configured != prompt)
321+
{
322+
return Err(RunnerError::configuration(format!(
323+
"route {route_name} maps completion target aliases to model {} with different system_prompt values",
324+
target.id
325+
)));
326+
}
327+
if let Some(prompt) = prompt {
328+
prompts.insert(target.id.clone(), prompt.to_string());
329+
}
330+
}
331+
let Some((response_name, dependency_name)) =
332+
route.algorithm.routing_response_and_dependency()
333+
else {
334+
return Ok((prompts, None));
335+
};
336+
let response = self.targets.get(response_name).ok_or_else(|| {
337+
RunnerError::configuration(format!("route references unknown target {response_name}"))
338+
})?;
339+
if !prompts.contains_key(&response.id) {
340+
return Ok((prompts, None));
341+
}
342+
let dependency = self.targets.get(dependency_name).ok_or_else(|| {
343+
RunnerError::configuration(format!("route references unknown target {dependency_name}"))
344+
})?;
345+
if response.id == dependency.id {
346+
return Err(RunnerError::configuration(format!(
347+
"route {route_name} cannot apply system_prompt to target {response_name}: model {} is also used by routing-only target {dependency_name}",
348+
response.id,
349+
)));
350+
}
351+
Ok((prompts, Some(response.id.clone())))
299352
}
300353

301354
fn fallback_base_url(&self) -> RunnerResult<Option<String>> {
@@ -419,6 +472,7 @@ struct TargetConfig {
419472
llm_client: String,
420473
#[serde(default)]
421474
extra_body: BTreeMap<String, Value>,
475+
system_prompt: Option<String>,
422476
}
423477

424478
#[derive(Clone, Copy, Debug, Deserialize)]
@@ -764,6 +818,49 @@ confidence_threshold = 0.5
764818
Ok(())
765819
}
766820

821+
#[test]
822+
fn aliased_completion_targets_reject_prompt_conflicts() {
823+
let configured = stage_config()
824+
.replace(
825+
"id = \"strong/model\"\nllm_client = \"responses\"",
826+
"id = \"strong/model\"\nllm_client = \"responses\"\nsystem_prompt = \"capable\"",
827+
)
828+
.replace(
829+
"[routes.stage]",
830+
"[targets.strong_alias]\nid = \"strong/model\"\nllm_client = \"responses\"\n\n[routes.stage]",
831+
)
832+
.replace("efficient_target = \"weak\"", "efficient_target = \"strong_alias\"");
833+
let message = error_message(&configured);
834+
assert!(
835+
message.contains("completion target aliases to model strong/model with different system_prompt values"),
836+
"unexpected error: {message}"
837+
);
838+
}
839+
840+
#[test]
841+
fn prompted_routing_response_cannot_share_a_model_with_a_dependency() {
842+
let configured = VALID_CONFIG
843+
.replace(
844+
"id = \"classifier/model\"\nllm_client = \"primary\"",
845+
"id = \"weak/model\"\nllm_client = \"primary\"",
846+
)
847+
.replace(
848+
"id = \"weak/model\"\nllm_client = \"anthropic\"",
849+
"id = \"weak/model\"\nllm_client = \"anthropic\"\nsystem_prompt = \"answer prompt\"",
850+
)
851+
.replace(
852+
"base_threshold = 0.5",
853+
"base_threshold = 0.5\nescalation = { confirmations = 1 }",
854+
);
855+
856+
let message = error_message(&configured);
857+
858+
assert!(
859+
message.contains("cannot apply system_prompt to target weak: model weak/model is also used by routing-only target classifier"),
860+
"unexpected error: {message}"
861+
);
862+
}
863+
767864
#[test]
768865
fn rejects_invalid_unreferenced_llm_client() {
769866
let invalid = format!(

crates/switchyard-server/CONFIGURATION.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@ max_retries = 2
1414
[targets.model]
1515
id = "provider/model"
1616
llm_client = "provider"
17+
system_prompt = "Follow this model's deployment instructions."
1718
extra_body = { chat_template_kwargs = { enable_thinking = false } }
1819
```
1920

21+
`system_prompt` is prepended when the target is a completion destination. Switchyard
22+
prepares each fallback independently, so a failed target's prompt is not carried to the next one.
23+
2024
`extra_body` is target-specific. It shallow-merges top-level provider options into
2125
the outbound request, while explicit request fields win on conflicts.
2226

crates/switchyard-server/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ max_retries = 2
1717
[targets.model_a]
1818
id = "model/a"
1919
llm_client = "example"
20+
system_prompt = "Use the fast path for routine work."
2021
extra_body = { service_tier = "priority" }
2122

2223
[targets.model_b]
@@ -83,6 +84,8 @@ client's `base_url` should receive the caller's login. A forwarding route must
8384
be called through the matching provider API.
8485
Target-level `extra_body` values are shallow-merged into the upstream request when
8586
the request does not already contain that key.
87+
Target-level `system_prompt` values are prepended when that target serves a completion.
88+
Selected and fallback targets are prepared independently.
8689
`max_retries` defaults to `2` and applies to transport failures, timeouts, HTTP 408/429, and 5xx
8790
responses.
8891

@@ -166,7 +169,8 @@ target and summarizes its score, confidence, and input-dimension histograms. The
166169
with `/v1/stats/reset`; the process-lifetime counters on `/metrics` remain cumulative.
167170

168171
Token counting selects an Anthropic-format completion target, preferring target names or model IDs
169-
containing `opus`, `sonnet`, then `haiku`. Other ties preserve the route's target order.
172+
containing `opus`, `sonnet`, then `haiku`. Other ties preserve the route's target order. Target
173+
system prompts are applied to answer calls, not token-count requests.
170174

171175
## Metrics
172176

0 commit comments

Comments
 (0)