Skip to content
Draft
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
255 changes: 212 additions & 43 deletions crates/libsy/src/algorithms/llm_class.rs

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions crates/switchyard-py/src/libsy_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ impl PyCustomClassifierConfig {
session_affinity=false,
message_hash_fallback=false,
recent_turn_window=None,
judge_max_images=None,
max_output_tokens=4096
))]
#[allow(clippy::too_many_arguments)]
Expand All @@ -179,6 +180,7 @@ impl PyCustomClassifierConfig {
session_affinity: bool,
message_hash_fallback: bool,
recent_turn_window: Option<usize>,
judge_max_images: Option<usize>,
max_output_tokens: u64,
) -> PyResult<Self> {
// Convert the Python schema into serde JSON and pair it with the target-selector policy;
Expand All @@ -191,6 +193,7 @@ impl PyCustomClassifierConfig {
inner.classify_trigger = classify_trigger(session_affinity);
inner.message_hash_fallback = message_hash_fallback;
inner.recent_turn_window = recent_turn_window;
inner.judge_max_images = judge_max_images;
inner.max_output_tokens = max_output_tokens;
Ok(Self { inner })
}
Expand Down Expand Up @@ -262,6 +265,7 @@ impl PyTaskClassifierConfig {
session_affinity=false,
message_hash_fallback=false,
recent_turn_window=None,
judge_max_images=None,
max_output_tokens=4096,
prompt=None,
response_format_type="json_schema"
Expand All @@ -273,6 +277,7 @@ impl PyTaskClassifierConfig {
session_affinity: bool,
message_hash_fallback: bool,
recent_turn_window: Option<usize>,
judge_max_images: Option<usize>,
max_output_tokens: u64,
prompt: Option<String>,
response_format_type: &str,
Expand All @@ -284,6 +289,7 @@ impl PyTaskClassifierConfig {
classify_trigger: classify_trigger(session_affinity),
message_hash_fallback,
recent_turn_window,
judge_max_images,
contract: classifier_contract(prompt, response_format_type)?,
max_output_tokens,
},
Expand Down
22 changes: 22 additions & 0 deletions crates/switchyard-runner/src/algorithm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ struct CapabilityClassifierRouteConfig {
classify_trigger: ClassifyTrigger,
message_hash_fallback: bool,
recent_turn_window: Option<usize>,
judge_max_images: Option<usize>,
prompt: Option<String>,
response_format_type: ClassifierResponseFormat,
max_output_tokens: u64,
Expand All @@ -132,6 +133,7 @@ struct CustomClassifierRouteConfig {
classify_trigger: ClassifyTrigger,
message_hash_fallback: bool,
recent_turn_window: Option<usize>,
judge_max_images: Option<usize>,
max_output_tokens: u64,
}

Expand Down Expand Up @@ -245,6 +247,9 @@ pub struct LlmClassifierRouteConfig {
/// How many trailing turns the judge sees. Unset shows it the opening task
/// and the latest user follow-up only.
pub recent_turn_window: Option<usize>,
/// Maximum judge images; unset preserves all, zero omits images, otherwise keeps newest.
#[serde(default)]
pub judge_max_images: Option<usize>,
/// Replaces the packaged judge prompt. Required in custom mode.
pub prompt: Option<String>,
/// How the judge is asked for structured output. Use `json_object` when the
Expand Down Expand Up @@ -464,6 +469,9 @@ pub struct StageClassifierConfig {
/// and the latest user follow-up only.
#[serde(default)]
pub recent_turn_window: Option<usize>,
/// Maximum judge images; unset preserves all, zero omits images, otherwise keeps newest.
#[serde(default)]
pub judge_max_images: Option<usize>,
/// Replaces the packaged judge prompt.
#[serde(default)]
pub prompt: Option<String>,
Expand Down Expand Up @@ -511,6 +519,7 @@ impl StageClassifierConfig {
classify_trigger: self.classify_trigger,
message_hash_fallback: self.message_hash_fallback,
recent_turn_window: self.recent_turn_window,
judge_max_images: self.judge_max_images,
contract: classifier_contract(self.prompt.as_deref())
.with_response_format_type(self.response_format_type),
max_output_tokens: self.max_output_tokens,
Expand Down Expand Up @@ -847,6 +856,7 @@ impl LlmClassifierRouteConfig {
classify_trigger,
message_hash_fallback,
recent_turn_window,
judge_max_images,
prompt,
response_format_type,
max_output_tokens,
Expand Down Expand Up @@ -902,13 +912,21 @@ impl LlmClassifierRouteConfig {
classify_trigger: *classify_trigger,
message_hash_fallback: *message_hash_fallback,
recent_turn_window: *recent_turn_window,
judge_max_images: *judge_max_images,
prompt: prompt.clone(),
response_format_type: *response_format_type,
max_output_tokens: *max_output_tokens,
},
))
}
ClassifierMode::Escalation => {
if judge_max_images.is_some() {
return Err(classifier_field_error(
route_name,
"judge_max_images",
"escalation",
));
}
reject_custom_fields(
route_name,
"escalation",
Expand Down Expand Up @@ -997,6 +1015,7 @@ impl LlmClassifierRouteConfig {
classify_trigger: *classify_trigger,
message_hash_fallback: *message_hash_fallback,
recent_turn_window: *recent_turn_window,
judge_max_images: *judge_max_images,
max_output_tokens: *max_output_tokens,
},
))
Expand Down Expand Up @@ -1083,6 +1102,7 @@ fn build_subagent_router_config(
config.policy.into_libsy(),
);
classifier_config.recent_turn_window = config.recent_turn_window;
classifier_config.judge_max_images = config.judge_max_images;
classifier_config.max_output_tokens = config.max_output_tokens;
let classifier = Arc::new(
LlmTaskClassifier::new(LlmClassifierConfig::Custom {
Expand Down Expand Up @@ -1180,6 +1200,7 @@ fn build_algorithm(
classify_trigger: config.classify_trigger,
message_hash_fallback: config.message_hash_fallback,
recent_turn_window: config.recent_turn_window,
judge_max_images: config.judge_max_images,
contract: classifier_contract(config.prompt.as_deref())
.with_response_format_type(config.response_format_type),
max_output_tokens: config.max_output_tokens,
Expand Down Expand Up @@ -1216,6 +1237,7 @@ fn build_algorithm(
classifier_config.classify_trigger = config.classify_trigger;
classifier_config.message_hash_fallback = config.message_hash_fallback;
classifier_config.recent_turn_window = config.recent_turn_window;
classifier_config.judge_max_images = config.judge_max_images;
classifier_config.max_output_tokens = config.max_output_tokens;
LlmTaskClassifier::new(LlmClassifierConfig::Custom {
default_target: config.default_target,
Expand Down
15 changes: 15 additions & 0 deletions crates/switchyard-runner/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,21 @@ target = "strong"
}
}

/// Reject an invalid image budget or a mode that never consumes it.
#[test]
fn rejects_invalid_judge_image_limits() {
let negative = VALID_CONFIG.replace(
"base_threshold = 0.5",
"base_threshold = 0.5\njudge_max_images = -1",
);
assert!(Runner::from_toml(&negative).is_err());
let escalation = VALID_CONFIG.replace(
"base_threshold = 0.5",
"mode = \"escalation\"\nescalation = { confirmations = 1 }\njudge_max_images = 1",
);
assert!(error_message(&escalation).contains("mode escalation cannot use judge_max_images"));
}

fn with_subagent_llm_classifier(config: &str, route: &str, extra: &str) -> String {
let mut configured = config.to_string();
configured.push_str(&format!("\n[routes.{route}.subagents]\n"));
Expand Down
94 changes: 94 additions & 0 deletions crates/switchyard-server/tests/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2485,6 +2485,100 @@ selector = "/decision/target"
Ok(())
}

/// Both judge modes limit their own images while forwarding the complete answer request.
#[tokio::test]
async fn classifier_image_limits_preserve_answer_images() -> TestResult {
let upstream = MockUpstream::start().await?;
let images = json!([
{"type": "text", "text": "Compare these images."},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,b2xk", "detail": "low"}},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,bmV3", "detail": "high"}}
]);
for custom in [false, true] {
for limit in [None, Some(0), Some(1)] {
for invalid in [false, true] {
if invalid && !custom {
continue;
}
let mode = if custom {
r#"mode = "custom"
models = { judge = ["classifier"], weak = ["weak"], premium = ["premium"], any = ["weak", "premium"] }
default_target = "weak"
response_schema = '{"type":"object","properties":{"decision":{"type":"object","properties":{"target":{"type":"string","enum":["weak","premium"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false}'
policy = { type = "target_selector", selector = "/decision/target" }"#
} else {
"mode = \"capability\"\nclassifier_target = \"classifier\"\nstrong_target = \"premium\"\nweak_target = \"weak\"\nbase_threshold = 0.5"
};
let limit_config = limit
.map(|n| format!("judge_max_images = {n}"))
.unwrap_or_default();
let prompt = if invalid {
"return an invalid verdict"
} else {
"route to premium"
};
let state = load_test_config(&format!(
r#"
schema_version = 1
[llm_clients.upstream]
format = "openai_chat"
base_url = "{}"
[targets.classifier]
id = "model/classifier"
llm_client = "upstream"
[targets.weak]
id = "model/weak"
llm_client = "upstream"
[targets.premium]
id = "model/premium"
llm_client = "upstream"
[routes.vision]
id = "vision"
type = "llm_classifier"
prompt = "{prompt}"
{mode}
{limit_config}
"#,
upstream.base_url
))?;
let app = build_switchyard_router(state);
upstream.calls.lock().await.clear();
let response = send(
&app,
"POST",
"/v1/chat/completions",
Some(json!({
"model": "vision", "messages": [{"role": "user", "content": images}]
})),
)
.await?;
assert_eq!(response.status, StatusCode::OK);
let calls = upstream.calls.lock().await;
assert_eq!(calls.len(), 2);
assert_eq!(calls[0]["model"], "model/classifier");
let judge = calls[0]["messages"][1]["content"].to_string();
assert!(judge.contains("Compare these images."));
assert_eq!(
judge.matches("image_url").count(),
limit.unwrap_or(2).min(2) * 2
);
assert_eq!(judge.contains("bmV3"), limit != Some(0));
assert_eq!(judge.contains("b2xk"), limit.is_none());
assert_eq!(calls[1]["messages"][0]["content"], images);
assert_eq!(
calls[1]["model"],
if custom && !invalid {
"model/premium"
} else {
"model/weak"
}
);
}
}
}
Ok(())
}

#[tokio::test]
async fn classifier_contract_overrides_reach_every_server_mode() -> TestResult {
let upstream = MockUpstream::start().await?;
Expand Down
61 changes: 59 additions & 2 deletions crates/switchyard-translation/tests/request_translation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ pub mod common;
use pretty_assertions::assert_eq;
use serde_json::{Value, json};
use switchyard_translation::{
ContentBlock, FormatId, LossyConversionPolicy, TranslationEngine, TranslationPolicy,
WireFormat, prepare_request_for_target, sanitize_anthropic_tool_use_id,
ContentBlock, FormatId, ImageSource, LlmRequest, LossyConversionPolicy, Message, Role,
TranslationEngine, TranslationPolicy, WireFormat, prepare_request_for_target,
sanitize_anthropic_tool_use_id,
};

use common::{REASONING_MODEL, normalized_policy, shell_tool_call};
Expand Down Expand Up @@ -718,6 +719,62 @@ fn preparing_without_a_prompt_preserves_exact_replay() -> TestResult {
Ok(())
}

// Fresh judge requests have no preserved wire body: every image must encode through the IR.
#[test]
fn normalized_images_encode_as_responses_image_urls() -> TestResult {
let engine = TranslationEngine::default();
for (source, expected) in [
(
ImageSource::Url {
url: "https://example.test/image.jpg".into(),
detail: Some("low".into()),
},
json!({"type": "input_image", "image_url": "https://example.test/image.jpg", "detail": "low"}),
),
(
ImageSource::Base64 {
media_type: Some("image/png".into()),
data: "aW1hZ2U=".into(),
},
json!({"type": "input_image", "image_url": "data:image/png;base64,aW1hZ2U="}),
),
(
ImageSource::Raw(json!({"type": "image", "source": {
"type": "base64", "media_type": "image/jpeg", "data": "aW1hZ2U="
}})),
json!({"type": "input_image", "image_url": "data:image/jpeg;base64,aW1hZ2U="}),
),
(
ImageSource::Url {
url: "data:image/png;base64,aW1hZ2U=".into(),
detail: Some("high".into()),
},
json!({"type": "input_image", "image_url": "data:image/png;base64,aW1hZ2U=", "detail": "high"}),
),
] {
let request = LlmRequest {
messages: vec![Message {
role: Role::User,
content: vec![ContentBlock::Image { source }],
}],
..LlmRequest::default()
};
let encoded =
engine.encode_request(WireFormat::OpenAiResponses, &request, &normalized_policy())?;
assert_eq!(encoded.body["input"][0]["content"][0], expected);
let decoded = engine.decode_request(
WireFormat::OpenAiResponses,
&encoded.body,
&normalized_policy(),
)?;
assert!(matches!(
decoded.request.messages[0].content[0],
ContentBlock::Image { .. }
));
}
Ok(())
}

// Verifies Anthropic-only request fields are dropped or mapped for OpenAI Chat.
#[test]
fn anthropic_request_translates_to_openai_chat_without_anthropic_only_fields() -> TestResult {
Expand Down
1 change: 1 addition & 0 deletions docs/reference/toml_schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ Runs one of three judge-backed modes: `capability`, `escalation`, or `custom`.
| `mode` | No | `capability` | Classifier behavior. Set it explicitly for new configurations. |
| `classifier_target` | Capability, escalation | — | Target the judge is called through. Not a routing destination. Custom mode uses `models.judge`. |
| `max_output_tokens` | No | `4096` | Maximum completion tokens for the judge verdict. Must be at least `1`. |
| `judge_max_images` | No | unset | Capability/custom modes only. `0` omits images from the judge; `N` keeps the newest `N` images across selected messages and nested tool results, in original order. Unset preserves all images. Does not resize images or change the answer request. |
| `response_format_type` | No | `json_schema` | Structured-output mode for capability and escalation judges. Use `json_object` when the provider does not support JSON Schema; Switchyard adds the schema to the prompt and validates the verdict locally. Custom mode always uses its configured JSON Schema. |

Capability mode classifies before serving. See
Expand Down
26 changes: 26 additions & 0 deletions docs/routing_algorithms/llm_classifier_routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,38 @@ for the server merge behavior.
| `base_threshold` | required | Lowest `p_solve` that routes a supported task to `weak_target`. Must be between `0` and `1`. |
| `threshold_step` | `0.0` | Amount added for each boundary step. Must be finite and non-negative, and `base_threshold + 2 * threshold_step` must not exceed `1`. |
| `recent_turn_window` | unset | When unset, the judge sees the opening user task and the latest user message when they differ. When set to `N`, it sees the opening user task and the last `N` conversation messages after that task. `0` keeps only the opening task. Client system and developer instructions are not shown to the judge. |
| `judge_max_images` | unset | Capability/custom modes: omit to preserve all images in the selected messages; `0` excludes images from the judge; `N` keeps the newest `N` images in their original order, including images in tool results. The answer request is unchanged. |
| `classify_trigger` | `every_request` | When the judge runs. `every_request` judges every request, tool continuations included. `user_turn` judges each new user message and holds that target across the tool calls between. `new_session` judges once and reuses that target for the session. |
| `message_hash_fallback` | `false` | When session metadata is absent, keys affinity from the first user-message text. Requires `classify_trigger = "new_session"`. |
| `prompt` | packaged capability prompt | Replaces the classifier's system prompt. The packaged verdict schema and routing policy remain active. |
| `response_format_type` | `json_schema` | Structured-output mode for capability and escalation judges. Use `json_object` for providers without JSON Schema support. |
| `max_output_tokens` | `4096` | Maximum completion tokens available to the classifier verdict. Must be at least `1`. |

### Control images shown to the judge

Capability and custom classifiers already pass images from selected messages to
the judge. `judge_max_images` controls how many of those images the judge receives:

```toml
judge_max_images = 0 # Text-only judge; the answer model still receives all images.
```

Set `judge_max_images = 1` to show the judge the newest image, or omit the field
to retain all images in the messages selected by `recent_turn_window`. The limit
is shared across those messages and nested tool results. Omitted images become
text markers, so image-only messages remain valid and their positions are visible.
The remaining images keep their original order, URLs/data, and detail settings.
Switchyard does not fetch or resize images for this limit.

Use `classify_trigger = "every_request"` for independent image requests. The
optional message-hash affinity key uses only text, so identical questions about
different images must not share that key. Escalation mode uses a text summary
and rejects `judge_max_images`.

The [Cosmos vision judge example](../../examples/vision_judge/README.md) includes
both modes, a pinned VANTAGE still image, Cosmos/Astra answer targets, and a live
runner that verifies the images sent to each model.

### Override the classifier prompt

Set `prompt` on the route when the packaged capability rubric does not describe
Expand Down
Loading
Loading