Skip to content

Commit 33ada06

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

7 files changed

Lines changed: 223 additions & 72 deletions

File tree

‎crates/switchyard-runner/src/algorithm.rs‎

Lines changed: 35 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ use libsy::{
1414
ClassifyTrigger, CompositeRouter, CompositeRouterConfig, CustomClassifierConfig,
1515
CustomClassifierPolicy, EscalationJudgeConfig, GateTrigger, HandoffNoteConfig,
1616
LlmClassifierConfig, LlmFallback, LlmTaskClassifier, Noop, Passthrough, PickerMode, Random,
17-
StageRouter, StageRouterConfig, SubagentRouter, SubagentRouterConfig, TargetPrompts,
18-
TaskClassifierConfig,
17+
StageRouter, StageRouterConfig, SubagentRouter, SubagentRouterConfig, TaskClassifierConfig,
1918
};
2019
use serde::Deserialize;
2120
use switchyard_protocol::ModelId;
@@ -395,12 +394,6 @@ pub struct StageTierConfig {
395394
/// Notes handed to a tier when the router switches to it.
396395
#[serde(default)]
397396
pub handoff_notes: Option<HandoffNoteConfig>,
398-
/// System prompt handed to the capable tier.
399-
#[serde(default)]
400-
pub capable_system_prompt: Option<String>,
401-
/// System prompt handed to the efficient tier.
402-
#[serde(default)]
403-
pub efficient_system_prompt: Option<String>,
404397
}
405398

406399
impl StageClassifierConfig {
@@ -531,6 +524,40 @@ impl AlgorithmSpec {
531524
}
532525
names
533526
}
527+
528+
/// Response target and routing-only dependency for routers that answer while routing.
529+
pub(crate) fn routing_response_and_dependency(&self) -> Option<(&str, &str)> {
530+
match self {
531+
Self::LlmClassifier { config, .. }
532+
if matches!(
533+
config.mode.unwrap_or(if config.escalation.is_some() {
534+
ClassifierMode::Escalation
535+
} else {
536+
ClassifierMode::Capability
537+
}),
538+
ClassifierMode::Escalation
539+
) =>
540+
{
541+
Some((
542+
config.weak_target.as_deref()?,
543+
config.classifier_target.as_str(),
544+
))
545+
}
546+
Self::Advisor {
547+
executor_target,
548+
advisor_target,
549+
..
550+
} => Some((executor_target, advisor_target)),
551+
Self::Noop { .. }
552+
| Self::Random { .. }
553+
| Self::Passthrough { .. }
554+
| Self::LlmClassifier { .. }
555+
| Self::StageRouter { .. }
556+
| Self::Composite { .. }
557+
| Self::PrefillRouter { .. } => None,
558+
}
559+
}
560+
534561
/// Builds this algorithm after resolving configured target names.
535562
pub fn build(
536563
&self,
@@ -963,8 +990,6 @@ fn build_algorithm(
963990
confidence_threshold,
964991
recent_turn_window,
965992
handoff_notes,
966-
capable_system_prompt,
967-
efficient_system_prompt,
968993
} = tiers;
969994
if matches!(picker, PickerMode::CapableFirst) {
970995
tracing::warn!(
@@ -976,12 +1001,6 @@ fn build_algorithm(
9761001
let mut config = StageRouterConfig::new(*picker, *confidence_threshold);
9771002
config.recent_window = *recent_turn_window;
9781003
config.handoff_notes = handoff_notes.clone();
979-
config.tier_prompts = tier_prompts(
980-
&capable,
981-
capable_system_prompt.as_deref(),
982-
&efficient,
983-
efficient_system_prompt.as_deref(),
984-
);
9851004
// The judge is called through its own target, so it is not a routing
9861005
// destination and stays out of the tier pair.
9871006
config.llm_fallback = classifier
@@ -1016,12 +1035,6 @@ fn build_algorithm(
10161035
StageRouterConfig::new(PickerMode::EfficientFirst, stage.confidence_threshold);
10171036
stage_config.recent_window = stage.recent_turn_window;
10181037
stage_config.handoff_notes = stage.handoff_notes.clone();
1019-
stage_config.tier_prompts = tier_prompts(
1020-
&capable,
1021-
stage.capable_system_prompt.as_deref(),
1022-
&efficient,
1023-
stage.efficient_system_prompt.as_deref(),
1024-
);
10251038
let config = CompositeRouterConfig {
10261039
judge_target,
10271040
judge: classifier.task_classifier_config(),
@@ -1159,23 +1172,6 @@ fn default_classifier_max_output_tokens() -> u64 {
11591172
TaskClassifierConfig::default().max_output_tokens
11601173
}
11611174

1162-
/// Keys each configured system prompt by the target it belongs to.
1163-
fn tier_prompts(
1164-
capable: &str,
1165-
capable_prompt: Option<&str>,
1166-
efficient: &str,
1167-
efficient_prompt: Option<&str>,
1168-
) -> TargetPrompts {
1169-
let mut prompts = TargetPrompts::default();
1170-
if let Some(prompt) = capable_prompt {
1171-
prompts = prompts.with(capable, prompt);
1172-
}
1173-
if let Some(prompt) = efficient_prompt {
1174-
prompts = prompts.with(efficient, prompt);
1175-
}
1176-
prompts
1177-
}
1178-
11791175
fn resolve_targets<'a>(
11801176
route_name: &str,
11811177
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
@@ -299,7 +299,60 @@ impl DeploymentConfig {
299299
let client: Arc<dyn RoutedLlmClient> = client.clone();
300300
by_model.insert(target.id.clone(), client);
301301
}
302-
Ok((ClientRouter::new(by_model), caller_auth))
302+
let (target_prompts, routing_answer_target) =
303+
self.build_route_target_prompts(route_name, route)?;
304+
let router =
305+
ClientRouter::new_with_target_prompts(by_model, target_prompts, routing_answer_target);
306+
Ok((router, caller_auth))
307+
}
308+
309+
/// Builds the effective prompt policy for this route's completion targets.
310+
fn build_route_target_prompts(
311+
&self,
312+
route_name: &str,
313+
route: &RouteConfig,
314+
) -> RunnerResult<(HashMap<ModelId, String>, Option<ModelId>)> {
315+
let mut prompts = HashMap::new();
316+
let mut aliases = HashMap::<&ModelId, Option<&str>>::new();
317+
for name in route.algorithm.routing_target_names() {
318+
let target = self.targets.get(name).ok_or_else(|| {
319+
RunnerError::configuration(format!("route references unknown target {name}"))
320+
})?;
321+
let prompt = target.system_prompt.as_deref();
322+
if aliases
323+
.insert(&target.id, prompt)
324+
.is_some_and(|configured| configured != prompt)
325+
{
326+
return Err(RunnerError::configuration(format!(
327+
"route {route_name} maps completion target aliases to model {} with different system_prompt values",
328+
target.id
329+
)));
330+
}
331+
if let Some(prompt) = prompt {
332+
prompts.insert(target.id.clone(), prompt.to_string());
333+
}
334+
}
335+
let Some((response_name, dependency_name)) =
336+
route.algorithm.routing_response_and_dependency()
337+
else {
338+
return Ok((prompts, None));
339+
};
340+
let response = self.targets.get(response_name).ok_or_else(|| {
341+
RunnerError::configuration(format!("route references unknown target {response_name}"))
342+
})?;
343+
if !prompts.contains_key(&response.id) {
344+
return Ok((prompts, None));
345+
}
346+
let dependency = self.targets.get(dependency_name).ok_or_else(|| {
347+
RunnerError::configuration(format!("route references unknown target {dependency_name}"))
348+
})?;
349+
if response.id == dependency.id {
350+
return Err(RunnerError::configuration(format!(
351+
"route {route_name} cannot apply system_prompt to target {response_name}: model {} is also used by routing-only target {dependency_name}",
352+
response.id,
353+
)));
354+
}
355+
Ok((prompts, Some(response.id.clone())))
303356
}
304357

305358
fn fallback_base_url(&self) -> RunnerResult<Option<String>> {
@@ -423,6 +476,7 @@ struct TargetConfig {
423476
llm_client: String,
424477
#[serde(default)]
425478
extra_body: BTreeMap<String, Value>,
479+
system_prompt: Option<String>,
426480
}
427481

428482
#[derive(Clone, Copy, Debug, Deserialize)]
@@ -768,6 +822,49 @@ confidence_threshold = 0.5
768822
Ok(())
769823
}
770824

825+
#[test]
826+
fn aliased_completion_targets_reject_prompt_conflicts() {
827+
let configured = stage_config()
828+
.replace(
829+
"id = \"strong/model\"\nllm_client = \"responses\"",
830+
"id = \"strong/model\"\nllm_client = \"responses\"\nsystem_prompt = \"capable\"",
831+
)
832+
.replace(
833+
"[routes.stage]",
834+
"[targets.strong_alias]\nid = \"strong/model\"\nllm_client = \"responses\"\n\n[routes.stage]",
835+
)
836+
.replace("efficient_target = \"weak\"", "efficient_target = \"strong_alias\"");
837+
let message = error_message(&configured);
838+
assert!(
839+
message.contains("completion target aliases to model strong/model with different system_prompt values"),
840+
"unexpected error: {message}"
841+
);
842+
}
843+
844+
#[test]
845+
fn prompted_routing_response_cannot_share_a_model_with_a_dependency() {
846+
let configured = VALID_CONFIG
847+
.replace(
848+
"id = \"classifier/model\"\nllm_client = \"primary\"",
849+
"id = \"weak/model\"\nllm_client = \"primary\"",
850+
)
851+
.replace(
852+
"id = \"weak/model\"\nllm_client = \"anthropic\"",
853+
"id = \"weak/model\"\nllm_client = \"anthropic\"\nsystem_prompt = \"answer prompt\"",
854+
)
855+
.replace(
856+
"base_threshold = 0.5",
857+
"base_threshold = 0.5\nescalation = { confirmations = 1 }",
858+
);
859+
860+
let message = error_message(&configured);
861+
862+
assert!(
863+
message.contains("cannot apply system_prompt to target weak: model weak/model is also used by routing-only target classifier"),
864+
"unexpected error: {message}"
865+
);
866+
}
867+
771868
#[test]
772869
fn rejects_invalid_unreferenced_llm_client() {
773870
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]
@@ -84,6 +85,8 @@ client's `base_url` should receive the caller's login. A forwarding route must
8485
be called through the matching provider API.
8586
Target-level `extra_body` values are shallow-merged into the upstream request when
8687
the request does not already contain that key.
88+
Target-level `system_prompt` values are prepended when that target serves a completion.
89+
Selected and fallback targets are prepared independently.
8790
`max_retries` defaults to `2` and applies to transport failures, timeouts, HTTP 408/429, and 5xx
8891
responses.
8992

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

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

174178
## Metrics
175179

0 commit comments

Comments
 (0)