Skip to content
Merged
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
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
Loading
Loading