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
73 changes: 34 additions & 39 deletions crates/switchyard-runner/src/algorithm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ use libsy::{
ClassifyTrigger, CompositeRouter, CompositeRouterConfig, CustomClassifierConfig,
CustomClassifierPolicy, EscalationJudgeConfig, GateTrigger, HandoffNoteConfig,
LlmClassifierConfig, LlmFallback, LlmTaskClassifier, Noop, Passthrough, PickerMode, Random,
StageRouter, StageRouterConfig, SubagentRouter, SubagentRouterConfig, TargetPrompts,
TaskClassifierConfig,
StageRouter, StageRouterConfig, SubagentRouter, SubagentRouterConfig, TaskClassifierConfig,
};
use serde::Deserialize;
use switchyard_protocol::ModelId;
Expand Down Expand Up @@ -378,12 +377,6 @@ pub struct StageTierConfig {
/// Notes handed to a tier when the router switches to it.
#[serde(default)]
pub handoff_notes: Option<HandoffNoteConfig>,
/// System prompt handed to the capable tier.
#[serde(default)]
pub capable_system_prompt: Option<String>,
/// System prompt handed to the efficient tier.
#[serde(default)]
pub efficient_system_prompt: Option<String>,
}

impl StageClassifierConfig {
Expand Down Expand Up @@ -513,6 +506,39 @@ impl AlgorithmSpec {
}
names
}

/// Response target and routing-only dependency for routers that answer while routing.
pub(crate) fn routing_response_and_dependency(&self) -> Option<(&str, &str)> {
match self {
Self::LlmClassifier { config, .. }
if matches!(
config.mode.unwrap_or(if config.escalation.is_some() {
ClassifierMode::Escalation
} else {
ClassifierMode::Capability
}),
ClassifierMode::Escalation
) =>
{
Some((
config.weak_target.as_deref()?,
config.classifier_target.as_str(),
))
}
Self::Advisor {
executor_target,
advisor_target,
..
} => Some((executor_target, advisor_target)),
Self::Noop { .. }
| Self::Random { .. }
| Self::Passthrough { .. }
| Self::LlmClassifier { .. }
| Self::StageRouter { .. }
| Self::Composite { .. } => None,
}
}

/// Builds this algorithm after resolving configured target names.
pub fn build(
&self,
Expand Down Expand Up @@ -945,8 +971,6 @@ fn build_algorithm(
confidence_threshold,
recent_turn_window,
handoff_notes,
capable_system_prompt,
efficient_system_prompt,
} = tiers;
if matches!(picker, PickerMode::CapableFirst) {
tracing::warn!(
Expand All @@ -958,12 +982,6 @@ fn build_algorithm(
let mut config = StageRouterConfig::new(*picker, *confidence_threshold);
config.recent_window = *recent_turn_window;
config.handoff_notes = handoff_notes.clone();
config.tier_prompts = tier_prompts(
&capable,
capable_system_prompt.as_deref(),
&efficient,
efficient_system_prompt.as_deref(),
);
// The judge is called through its own target, so it is not a routing
// destination and stays out of the tier pair.
config.llm_fallback = classifier
Expand Down Expand Up @@ -998,12 +1016,6 @@ fn build_algorithm(
StageRouterConfig::new(PickerMode::EfficientFirst, stage.confidence_threshold);
stage_config.recent_window = stage.recent_turn_window;
stage_config.handoff_notes = stage.handoff_notes.clone();
stage_config.tier_prompts = tier_prompts(
&capable,
stage.capable_system_prompt.as_deref(),
&efficient,
stage.efficient_system_prompt.as_deref(),
);
let config = CompositeRouterConfig {
judge_target,
judge: classifier.task_classifier_config(),
Expand Down Expand Up @@ -1101,23 +1113,6 @@ fn default_classifier_max_output_tokens() -> u64 {
TaskClassifierConfig::default().max_output_tokens
}

/// Keys each configured system prompt by the target it belongs to.
fn tier_prompts(
capable: &str,
capable_prompt: Option<&str>,
efficient: &str,
efficient_prompt: Option<&str>,
) -> TargetPrompts {
let mut prompts = TargetPrompts::default();
if let Some(prompt) = capable_prompt {
prompts = prompts.with(capable, prompt);
}
if let Some(prompt) = efficient_prompt {
prompts = prompts.with(efficient, prompt);
}
prompts
}

fn resolve_targets<'a>(
route_name: &str,
names: impl IntoIterator<Item = &'a str>,
Expand Down
99 changes: 98 additions & 1 deletion crates/switchyard-runner/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,60 @@ impl DeploymentConfig {
let client: Arc<dyn RoutedLlmClient> = client.clone();
by_model.insert(target.id.clone(), client);
}
Ok((ClientRouter::new(by_model), caller_auth))
let (target_prompts, routing_answer_target) =
self.build_route_target_prompts(route_name, route)?;
let router =
ClientRouter::new_with_target_prompts(by_model, target_prompts, routing_answer_target);
Ok((router, caller_auth))
}

/// Builds the effective prompt policy for this route's completion targets.
fn build_route_target_prompts(
&self,
route_name: &str,
route: &RouteConfig,
) -> RunnerResult<(HashMap<ModelId, String>, Option<ModelId>)> {
let mut prompts = HashMap::new();
let mut aliases = HashMap::<&ModelId, Option<&str>>::new();
for name in route.algorithm.routing_target_names() {
let target = self.targets.get(name).ok_or_else(|| {
RunnerError::configuration(format!("route references unknown target {name}"))
})?;
let prompt = target.system_prompt.as_deref();
if aliases
.insert(&target.id, prompt)
.is_some_and(|configured| configured != prompt)
{
return Err(RunnerError::configuration(format!(
"route {route_name} maps completion target aliases to model {} with different system_prompt values",
target.id
)));
}
if let Some(prompt) = prompt {
prompts.insert(target.id.clone(), prompt.to_string());
}
}
let Some((response_name, dependency_name)) =
route.algorithm.routing_response_and_dependency()
else {
return Ok((prompts, None));
};
let response = self.targets.get(response_name).ok_or_else(|| {
RunnerError::configuration(format!("route references unknown target {response_name}"))
})?;
if !prompts.contains_key(&response.id) {
return Ok((prompts, None));
}
let dependency = self.targets.get(dependency_name).ok_or_else(|| {
RunnerError::configuration(format!("route references unknown target {dependency_name}"))
})?;
if response.id == dependency.id {
return Err(RunnerError::configuration(format!(
"route {route_name} cannot apply system_prompt to target {response_name}: model {} is also used by routing-only target {dependency_name}",
response.id,
)));
}
Ok((prompts, Some(response.id.clone())))
}

fn fallback_base_url(&self) -> RunnerResult<Option<String>> {
Expand Down Expand Up @@ -419,6 +472,7 @@ struct TargetConfig {
llm_client: String,
#[serde(default)]
extra_body: BTreeMap<String, Value>,
system_prompt: Option<String>,
}

#[derive(Clone, Copy, Debug, Deserialize)]
Expand Down Expand Up @@ -764,6 +818,49 @@ confidence_threshold = 0.5
Ok(())
}

#[test]
fn aliased_completion_targets_reject_prompt_conflicts() {
let configured = stage_config()
.replace(
"id = \"strong/model\"\nllm_client = \"responses\"",
"id = \"strong/model\"\nllm_client = \"responses\"\nsystem_prompt = \"capable\"",
)
.replace(
"[routes.stage]",
"[targets.strong_alias]\nid = \"strong/model\"\nllm_client = \"responses\"\n\n[routes.stage]",
)
.replace("efficient_target = \"weak\"", "efficient_target = \"strong_alias\"");
let message = error_message(&configured);
assert!(
message.contains("completion target aliases to model strong/model with different system_prompt values"),
"unexpected error: {message}"
);
}

#[test]
fn prompted_routing_response_cannot_share_a_model_with_a_dependency() {
let configured = VALID_CONFIG
.replace(
"id = \"classifier/model\"\nllm_client = \"primary\"",
"id = \"weak/model\"\nllm_client = \"primary\"",
)
.replace(
"id = \"weak/model\"\nllm_client = \"anthropic\"",
"id = \"weak/model\"\nllm_client = \"anthropic\"\nsystem_prompt = \"answer prompt\"",
)
.replace(
"base_threshold = 0.5",
"base_threshold = 0.5\nescalation = { confirmations = 1 }",
);

let message = error_message(&configured);

assert!(
message.contains("cannot apply system_prompt to target weak: model weak/model is also used by routing-only target classifier"),
"unexpected error: {message}"
);
}

#[test]
fn rejects_invalid_unreferenced_llm_client() {
let invalid = format!(
Expand Down
4 changes: 4 additions & 0 deletions crates/switchyard-server/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@ max_retries = 2
[targets.model]
id = "provider/model"
llm_client = "provider"
system_prompt = "Follow this model's deployment instructions."
extra_body = { chat_template_kwargs = { enable_thinking = false } }
```

`system_prompt` is prepended when the target is a completion destination. Switchyard
prepares each fallback independently, so a failed target's prompt is not carried to the next one.

`extra_body` is target-specific. It shallow-merges top-level provider options into
the outbound request, while explicit request fields win on conflicts.

Expand Down
6 changes: 5 additions & 1 deletion crates/switchyard-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ max_retries = 2
[targets.model_a]
id = "model/a"
llm_client = "example"
system_prompt = "Use the fast path for routine work."
extra_body = { service_tier = "priority" }

[targets.model_b]
Expand Down Expand Up @@ -83,6 +84,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.
Target-level `system_prompt` values are prepended when that target serves a completion.
Selected and fallback targets are prepared independently.
`max_retries` defaults to `2` and applies to transport failures, timeouts, HTTP 408/429, and 5xx
responses.

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

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

## Metrics

Expand Down
Loading
Loading