Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
7 changes: 6 additions & 1 deletion crates/libsy/src/algorithms/llm_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ use switchyard_protocol::{ContentBlock, Decision, Message, ModelId, Role};
use super::fall_through::{DefaultTarget, FallThrough};
use super::util::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS;
use super::util::affinity::AffinityRouter;
use super::util::classifier_contract::{ClassifierContract, ClassifierContractConfig};
use super::util::classifier_contract::{
ClassifierContract, ClassifierContractConfig, ClassifierResponseFormat,
};
use super::util::escalation::{self, EscalationJudge, EscalationJudgeConfig, EscalationPolicy};
use super::util::llm_judge::{
ClassifierInput, JsonSchemaDecoder, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig,
Expand Down Expand Up @@ -261,6 +263,8 @@ struct TaskClassifierConfigWire {
recent_turn_window: Option<usize>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
response_format_type: ClassifierResponseFormat,
#[serde(default = "default_judge_max_output_tokens")]
max_output_tokens: u64,
}
Expand All @@ -275,6 +279,7 @@ impl<'de> Deserialize<'de> for TaskClassifierConfig {
if let Some(prompt) = wire.prompt {
contract = contract.with_prompt(prompt);
}
contract = contract.with_response_format_type(wire.response_format_type);
Ok(Self {
base_threshold: wire.base_threshold,
threshold_step: wire.threshold_step,
Expand Down
97 changes: 78 additions & 19 deletions crates/libsy/src/algorithms/util/classifier_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,26 @@ use serde_json::{Value, json};

use crate::{LibsyError, Result};

/// Provider-side structured-output mode used by a classifier judge.
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ClassifierResponseFormat {
/// Send the verdict schema through the provider's strict JSON Schema wrapper.
#[default]
JsonSchema,
/// Request a JSON object and enforce the verdict schema locally.
JsonObject,
}

/// User-configurable parts of a classifier's prompt and verdict contract.
///
/// Fields are private so new contract settings can be added without breaking Rust struct literals.
#[derive(Clone, Debug, Default, Deserialize)]
pub struct ClassifierContractConfig {
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
response_format_type: ClassifierResponseFormat,
}

impl ClassifierContractConfig {
Expand All @@ -29,6 +42,20 @@ impl ClassifierContractConfig {
pub fn prompt(&self) -> Option<&str> {
self.prompt.as_deref()
}

/// Selects the provider-side structured-output mode.
pub fn with_response_format_type(
mut self,
response_format_type: ClassifierResponseFormat,
) -> Self {
self.response_format_type = response_format_type;
self
}

/// Returns the configured provider-side structured-output mode.
pub fn response_format_type(&self) -> ClassifierResponseFormat {
self.response_format_type
}
}

/// Rendered prompt and response format for one classifier.
Expand All @@ -42,8 +69,9 @@ pub(crate) struct ClassifierContract {
impl ClassifierContract {
/// Builds a contract from user settings and packaged defaults.
///
/// The response format must contain `json_schema.schema` and is retained separately for the
/// model request. Schemas are never copied into the system prompt.
/// The packaged response format must contain `json_schema.schema`. JSON Schema mode retains
/// that wrapper for the model request; JSON Object mode moves the schema into the prompt and
/// compiles it for local validation.
pub(crate) fn from_config(
config: &ClassifierContractConfig,
default_prompt: &str,
Expand All @@ -56,7 +84,31 @@ impl ClassifierContract {
message: format!("response schema is invalid: {error}"),
}
})?;
Self::from_response_format(prompt_template, response_format, None)
let schema = response_format
.pointer("/json_schema/schema")
.ok_or_else(|| LibsyError::AlgorithmError {
message: "response schema has no json_schema.schema".to_string(),
})?;
match config.response_format_type() {
ClassifierResponseFormat::JsonSchema => {
Self::from_response_format(prompt_template, response_format, None)
}
ClassifierResponseFormat::JsonObject => {
validate_prompt(prompt_template)?;
let validator = compile_schema(schema)?;
let rendered_schema = serde_json::to_string_pretty(schema).map_err(|error| {
algorithm_error(format!("response schema could not be rendered: {error}"))
})?;
let system_prompt = format!(
"{prompt_template}\n\nReturn exactly one JSON object matching this JSON Schema:\n{rendered_schema}"
);
Self::from_response_format(
&system_prompt,
json!({"type": "json_object"}),
Some(validator),
)
}
}
}

/// Builds a provider response format around a user-supplied inner JSON Schema.
Expand Down Expand Up @@ -88,21 +140,7 @@ impl ClassifierContract {
response_format: Value,
validator: Option<Validator>,
) -> Result<Self> {
if prompt_template.trim().is_empty() {
return Err(LibsyError::AlgorithmError {
message: "classifier prompt must not be empty".to_string(),
});
}
if prompt_template.contains("{{RESPONSE_SCHEMA}}") {
return Err(LibsyError::AlgorithmError {
message: "classifier prompt must not include {{RESPONSE_SCHEMA}}; the response schema is sent separately".to_string(),
});
}
response_format
.pointer("/json_schema/schema")
.ok_or_else(|| LibsyError::AlgorithmError {
message: "response schema has no json_schema.schema".to_string(),
})?;
validate_prompt(prompt_template)?;

Ok(Self {
system_prompt: prompt_template.to_string(),
Expand All @@ -119,6 +157,11 @@ impl ClassifierContract {
&self.response_format
}

/// Whether the provider response must be checked against the compiled schema locally.
pub(crate) fn validates_locally(&self) -> bool {
self.validator.is_some()
}

/// Validates a dynamic verdict when this contract carries a runtime schema validator.
pub(crate) fn validate_verdict(&self, verdict: &Value) -> Result<()> {
let Some(validator) = &self.validator else {
Expand All @@ -132,6 +175,18 @@ impl ClassifierContract {
}
}

fn validate_prompt(prompt_template: &str) -> Result<()> {
if prompt_template.trim().is_empty() {
return Err(algorithm_error("classifier prompt must not be empty"));
}
if prompt_template.contains("{{RESPONSE_SCHEMA}}") {
return Err(algorithm_error(
"classifier prompt must not include {{RESPONSE_SCHEMA}}; remove the placeholder because Switchyard supplies the schema automatically",
));
}
Ok(())
}

fn compile_schema(schema: &Value) -> Result<Validator> {
if !schema.is_object() {
return Err(algorithm_error("response_schema must be a JSON object"));
Expand Down Expand Up @@ -223,7 +278,11 @@ mod tests {
)
.expect_err("schema placeholders should be rejected");

assert!(error.to_string().contains("schema is sent separately"));
assert!(
error
.to_string()
.contains("Switchyard supplies the schema automatically")
);
}

#[test]
Expand Down
60 changes: 58 additions & 2 deletions crates/libsy/src/algorithms/util/llm_judge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,19 @@ where
fn decode(
&self,
response: &AggLlmResponse,
_contract: &ClassifierContract,
contract: &ClassifierContract,
) -> Result<Self::Verdict> {
parse_json_verdict(response)
if !contract.validates_locally() {
return parse_json_verdict(response);
}
let verdict = parse_json_verdict::<Value>(response)?;
contract.validate_verdict(&verdict)?;
serde_json::from_value(verdict).map_err(|error| LibsyError::AlgorithmError {
message: format!(
"judge reply did not parse as {}: {error}",
std::any::type_name::<Self::Verdict>()
),
})
}
}

Expand Down Expand Up @@ -353,6 +363,11 @@ mod tests {
ok: bool,
}

#[derive(Debug, Deserialize, PartialEq)]
struct ScoreVerdict {
score: f64,
}

struct TestJudge;

impl Judge for TestJudge {
Expand Down Expand Up @@ -416,6 +431,47 @@ mod tests {
Ok(())
}

#[test]
fn typed_decoder_enforces_a_json_object_contract_locally() -> Result<()> {
use super::super::classifier_contract::{
ClassifierContractConfig, ClassifierResponseFormat,
};

let config = ClassifierContractConfig::default()
.with_response_format_type(ClassifierResponseFormat::JsonObject);
let contract = ClassifierContract::from_config(
&config,
"Return one JSON score.",
r#"{
"type": "json_schema",
"json_schema": {
"name": "ScoreVerdict",
"schema": {
"type": "object",
"properties": {"score": {"type": "number"}},
"required": ["score"],
"additionalProperties": false
}
}
}"#,
)?;
let decoder = SerdeDecoder::<ScoreVerdict>::new();

let error = decoder
.decode(
&text_response(None, r#"{"score":0.5,"unexpected":true}"#),
&contract,
)
.expect_err("an extra property should fail the local schema");

assert!(error.to_string().contains("did not match response_schema"));
assert_eq!(
decoder.decode(&text_response(None, r#"{"score":0.5}"#), &contract)?,
ScoreVerdict { score: 0.5 }
);
Ok(())
}

fn buffered(completion: &str) -> Response {
Response {
llm_response: LlmResponse::Agg(text_response(None, completion)),
Expand Down
4 changes: 3 additions & 1 deletion crates/libsy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ pub use algorithms::passthrough::Passthrough;
pub use algorithms::rand::{Random, RandomClassifier};
pub use algorithms::stage::{LlmFallback, StageRouter, StageRouterConfig};
pub use algorithms::util::affinity::AffinityRouter;
pub use algorithms::util::classifier_contract::ClassifierContractConfig;
pub use algorithms::util::classifier_contract::{
ClassifierContractConfig, ClassifierResponseFormat,
};
pub use algorithms::util::escalation::EscalationJudgeConfig;
pub use algorithms::util::prompts::{SystemPromptProcessor, TargetPrompts, append_note};
pub use algorithms::util::subagent::SubagentOverride;
Expand Down
22 changes: 17 additions & 5 deletions crates/switchyard-py/src/libsy_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use http::header::{HeaderName, HeaderValue};
use pyo3::exceptions::{PyBaseException, PyStopAsyncIteration, PyTypeError, PyValueError};
use pyo3::prelude::*;
use switchyard_libsy::{
Algorithm, CallModel, ClassifierContractConfig, HandoffNoteConfig,
Algorithm, CallModel, ClassifierContractConfig, ClassifierResponseFormat, HandoffNoteConfig,
LibsyError as RustLibsyError, LlmClassifierConfig, LlmFallback, LlmTaskClassifier, Noop,
PickerMode, Random, StageRouter, StageRouterConfig, Step as RustStep, StepStream,
TaskClassifierConfig,
Expand Down Expand Up @@ -68,7 +68,8 @@ impl PyTaskClassifierConfig {
message_hash_fallback=false,
recent_turn_window=None,
max_output_tokens=4096,
prompt=None
prompt=None,
response_format_type="json_schema"
))]
#[allow(clippy::too_many_arguments)]
fn new(
Expand All @@ -79,12 +80,23 @@ impl PyTaskClassifierConfig {
recent_turn_window: Option<usize>,
max_output_tokens: u64,
prompt: Option<String>,
) -> Self {
response_format_type: &str,
) -> PyResult<Self> {
let mut contract = ClassifierContractConfig::default();
if let Some(prompt) = prompt {
contract = contract.with_prompt(prompt);
}
Self {
let response_format_type = match response_format_type {
"json_schema" => ClassifierResponseFormat::JsonSchema,
"json_object" => ClassifierResponseFormat::JsonObject,
other => {
return Err(PyValueError::new_err(format!(
"response_format_type must be 'json_schema' or 'json_object', got {other:?}"
)));
}
};
contract = contract.with_response_format_type(response_format_type);
Ok(Self {
inner: TaskClassifierConfig {
base_threshold,
threshold_step,
Expand All @@ -94,7 +106,7 @@ impl PyTaskClassifierConfig {
contract,
max_output_tokens,
},
}
})
}
}

Expand Down
18 changes: 12 additions & 6 deletions crates/switchyard-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ use std::path::Path;
use std::sync::Arc;

use libsy::{
Algorithm, ClassifierContractConfig, CustomClassifierConfig, CustomClassifierPolicy,
EscalationJudgeConfig, HandoffNoteConfig, LlmClassifierConfig, LlmFallback, LlmTaskClassifier,
Noop, Passthrough, PickerMode, Random, StageRouter, StageRouterConfig, TargetPrompts,
TaskClassifierConfig,
Algorithm, ClassifierContractConfig, ClassifierResponseFormat, CustomClassifierConfig,
CustomClassifierPolicy, EscalationJudgeConfig, HandoffNoteConfig, LlmClassifierConfig,
LlmFallback, LlmTaskClassifier, Noop, Passthrough, PickerMode, Random, StageRouter,
StageRouterConfig, TargetPrompts, TaskClassifierConfig,
};
use serde::Deserialize;
use serde_json::Value;
Expand Down Expand Up @@ -443,6 +443,8 @@ struct StageClassifierConfig {
recent_turn_window: Option<usize>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
response_format_type: ClassifierResponseFormat,
#[serde(default = "default_classifier_max_output_tokens")]
max_output_tokens: u64,
}
Expand All @@ -455,7 +457,8 @@ impl StageClassifierConfig {
session_affinity: self.session_affinity,
message_hash_fallback: self.message_hash_fallback,
recent_turn_window: self.recent_turn_window,
contract: classifier_contract(self.prompt.as_deref()),
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 @@ -1158,7 +1161,10 @@ target = "weak"
"base_threshold = 0.5",
"base_threshold = 0.5\nprompt = \"{{RESPONSE_SCHEMA}}\"",
);
assert!(error_message(&schema_placeholder).contains("schema is sent separately"));
assert!(
error_message(&schema_placeholder)
.contains("Switchyard supplies the schema automatically")
);
Ok(())
}

Expand Down
Loading