Skip to content
Closed
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
113 changes: 107 additions & 6 deletions crates/switchyard-runner/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use switchyard_llm_client::{
};
use switchyard_protocol::{Category, ModelId, RoutedDecisionClient, RoutedLlmClient, WireFormat};

use crate::privacy::{DeterministicDetector, PrivacyPolicy};
use crate::privacy::{DeterministicDetector, PrivacyPolicy, SemanticPrivacyClassifier};
use crate::route::ExecutionLane;
use crate::{
AlgorithmSpec, AuxiliaryTarget, CallerAuthKind, DecisionTarget, ModelCapabilities, Route,
Expand Down Expand Up @@ -86,10 +86,18 @@ struct PrivacyConfig {
restricted_targets: BTreeMap<String, String>,
restricted_decision_target: Option<String>,
deterministic: Option<DeterministicConfig>,
classifier: Option<PrivacyClassifierConfig>,
#[serde(default)]
accept_external_signal: bool,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PrivacyClassifierConfig {
target: String,
clear_threshold: f64,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct DeterministicConfig {
Expand Down Expand Up @@ -346,7 +354,10 @@ impl DeploymentConfig {
"route {route_name} cannot use privacy with prefill_router"
)));
}
if !config.accept_external_signal && config.deterministic.is_none() {
if !config.accept_external_signal
&& config.deterministic.is_none()
&& config.classifier.is_none()
{
return Err(RunnerError::configuration(format!(
"route {route_name} privacy must configure at least one request input"
)));
Expand Down Expand Up @@ -382,6 +393,12 @@ impl DeploymentConfig {
"route {route_name} cannot use privacy with forward_auth"
)));
}
let classifier = self.build_privacy_classifier(
route_name,
config.classifier.as_ref(),
standard_decision,
decision_clients,
)?;
let (restricted, _) = self.build_lane(
route_name,
route,
Expand All @@ -390,18 +407,50 @@ impl DeploymentConfig {
clients,
)?;
Ok(Some(BuiltPrivacy {
policy: PrivacyPolicy::new(config.accept_external_signal, detectors).map_err(
|error| {
policy: PrivacyPolicy::new(config.accept_external_signal, detectors, classifier)
.map_err(|error| {
RunnerError::configuration_source(
format!("route {route_name} privacy detectors could not be compiled"),
error,
)
},
)?,
})?,
restricted,
}))
}

fn build_privacy_classifier(
&self,
route_name: &str,
classifier: Option<&PrivacyClassifierConfig>,
standard_decision: Option<ResolvedDecisionTarget<'_>>,
decision_clients: &BTreeMap<String, Arc<dyn RoutedDecisionClient>>,
) -> RunnerResult<Option<SemanticPrivacyClassifier>> {
let Some(classifier) = classifier else {
return Ok(None);
};
if !(0.0..=1.0).contains(&classifier.clear_threshold) {
return Err(RunnerError::configuration(format!(
"route {route_name} privacy classifier clear_threshold must be between 0 and 1, got {}",
classifier.clear_threshold
)));
}
let target =
self.resolve_decision_target(route_name, &classifier.target, decision_clients)?;
if standard_decision.is_some_and(|standard| {
standard.model == target.model && Arc::ptr_eq(standard.client, target.client)
}) {
return Err(RunnerError::configuration(format!(
"route {route_name} privacy classifier target {} overlaps the standard execution lane",
classifier.target
)));
}
Ok(Some(SemanticPrivacyClassifier::new(
target.model.clone(),
Arc::clone(target.client),
classifier.clear_threshold,
)))
}

fn uses_forward_auth(&self, targets: &LaneTargets<'_>) -> bool {
targets.values().any(|target| {
self.llm_clients
Expand Down Expand Up @@ -1279,6 +1328,30 @@ detectors = ["bearer_token"]
)
}

fn semantic_privacy_config() -> String {
format!(
r#"{VALID_CONFIG}

[decision_clients.privacy]
format = "system_one"
endpoint = "https://example.test/v1/systemone"
api_key_env = "PATH"
timeout_ms = 5000

[decision_targets.privacy_judge]
id = "privacy/model"
decision_client = "privacy"

[routes.passthrough.privacy.restricted_targets]
weak = "strong"

[routes.passthrough.privacy.classifier]
target = "privacy_judge"
clear_threshold = 0.9
"#
)
}

#[test]
fn public_runner_from_toml_builds_a_deployment() -> RunnerResult<()> {
let runner = Runner::from_toml(VALID_CONFIG)?;
Expand Down Expand Up @@ -1378,6 +1451,34 @@ detectors = ["bearer_token"]
Ok(())
}

#[test]
fn semantic_privacy_classifier_config_is_bounded_and_isolated() -> RunnerResult<()> {
let configured = semantic_privacy_config();
Runner::from_toml(&configured)?;

for (invalid, expected) in [
(
configured.replace("target = \"privacy_judge\"", "target = \"missing\""),
"requires unknown decision target missing",
),
(
configured.replace("clear_threshold = 0.9", "clear_threshold = 1.1"),
"clear_threshold must be between 0 and 1",
),
(
format!(
"{}\n[routes.typed.privacy.classifier]\n\
target = \"standard\"\nclear_threshold = 0.9\n",
decision_privacy_config()
),
"overlaps the standard execution lane",
),
] {
assert!(error_message(&invalid).contains(expected));
}
Ok(())
}

#[test]
fn duplicate_route_ids_are_rejected() {
let config = format!(
Expand Down
92 changes: 70 additions & 22 deletions crates/switchyard-runner/src/privacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! Privacy policy used to select a route's execution lane.

mod deterministic;
mod semantic;

use serde_json::Value;
use strum_macros::{EnumString, IntoStaticStr};
Expand All @@ -12,12 +13,14 @@ use switchyard_protocol::{LlmClientError, Request, WireFormat};
const EXTERNAL_RESTRICTION_KEY: &str = "switchyard.internal.external_privacy_restriction";
pub(crate) use deterministic::DeterministicDetector;
use deterministic::{Assessment, Inspector};
pub(crate) use semantic::SemanticPrivacyClassifier;
pub(crate) const SELECTED_LANE_KEY: &str = "switchyard.internal.privacy_lane";
const RESPONSES_STATE_FIELDS: [&str; 2] = ["previous_response_id", "conversation"];

pub(crate) struct PrivacyPolicy {
accept_external_signal: bool,
inspector: Option<Inspector>,
classifier: Option<SemanticPrivacyClassifier>,
}

/// Target set allowed to serve one request.
Expand Down Expand Up @@ -58,6 +61,8 @@ pub(crate) struct PrivacyDecision {
pub(crate) lane: PrivacyLane,
pub(crate) source: PrivacySource,
pub(crate) reason_code: &'static str,
pub(crate) clear_score: Option<f64>,
pub(crate) clear_threshold: Option<f64>,
}

#[derive(Clone, Copy, IntoStaticStr)]
Expand All @@ -66,6 +71,7 @@ pub(crate) enum PrivacySource {
Policy,
ExternalSignal,
Deterministic,
SemanticClassifier,
}

impl PrivacySource {
Expand All @@ -80,6 +86,23 @@ impl PrivacyDecision {
lane,
source,
reason_code,
clear_score: None,
clear_threshold: None,
}
}

const fn semantic(
lane: PrivacyLane,
reason_code: &'static str,
clear_score: Option<f64>,
clear_threshold: f64,
) -> Self {
Self {
lane,
source: PrivacySource::SemanticClassifier,
reason_code,
clear_score,
clear_threshold: Some(clear_threshold),
}
}

Expand Down Expand Up @@ -118,14 +141,24 @@ impl PrivacyPolicy {
pub(crate) fn new(
accept_external_signal: bool,
detectors: Option<Vec<DeterministicDetector>>,
classifier: Option<SemanticPrivacyClassifier>,
) -> Result<Self, regex::Error> {
let inspector = match detectors {
Some(detectors) => Some(Inspector::new(detectors)?),
None if classifier.is_some() => Some(Inspector::structural()),
None => None,
};
Ok(Self {
accept_external_signal,
inspector: detectors.map(Inspector::new).transpose()?,
inspector,
classifier,
})
}

pub(crate) fn decide(&self, request: &Request) -> Result<PrivacyDecision, LlmClientError> {
pub(crate) async fn decide(
&self,
request: &Request,
) -> Result<PrivacyDecision, LlmClientError> {
if has_external_restriction(request) {
if !self.accept_external_signal {
return Err(external_signal_not_accepted());
Expand All @@ -136,18 +169,29 @@ impl PrivacyPolicy {
"restricted",
));
}
let Some(inspector) = &self.inspector else {
return Ok(PrivacyDecision::all_clear());
};
Ok(match inspector.inspect(request) {
Assessment::Restricted(reason_code) | Assessment::Indeterminate(reason_code) => {
PrivacyDecision::new(
PrivacyLane::Restricted,
PrivacySource::Deterministic,
reason_code,
)
if let Some(inspector) = &self.inspector {
match inspector.inspect(request) {
Assessment::Restricted(reason_code) => {
return Ok(PrivacyDecision::new(
PrivacyLane::Restricted,
PrivacySource::Deterministic,
reason_code,
));
}
Assessment::Indeterminate(reason_code) => {
return Ok(PrivacyDecision::new(
PrivacyLane::Restricted,
PrivacySource::Policy,
reason_code,
));
}
Assessment::Clear => {}
}
Assessment::Clear => PrivacyDecision::all_clear(),
}

Ok(match &self.classifier {
Some(classifier) => classifier.assess(request).await,
None => PrivacyDecision::all_clear(),
})
}
}
Expand Down Expand Up @@ -191,21 +235,23 @@ mod tests {
use super::*;
use switchyard_protocol::{ContentBlock, Message, Role};

#[test]
fn external_restriction_requires_route_opt_in() {
#[tokio::test]
async fn external_restriction_requires_route_opt_in() {
let mut request = Request::default();
mark_privacy_restricted(&mut request);

assert!(
PrivacyPolicy::new(false, None)
PrivacyPolicy::new(false, None, None)
.expect("empty detector configuration should compile")
.decide(&request)
.await
.is_err()
);
assert!(matches!(
PrivacyPolicy::new(true, None)
PrivacyPolicy::new(true, None, None)
.expect("empty detector configuration should compile")
.decide(&request),
.decide(&request)
.await,
Ok(PrivacyDecision {
lane: PrivacyLane::Restricted,
..
Expand All @@ -220,15 +266,16 @@ mod tests {
raw: Value::Null,
}],
});
let decision = PrivacyPolicy::new(true, None)
let decision = PrivacyPolicy::new(true, None, None)
.expect("empty detector configuration should compile")
.decide(&opaque)
.await
.expect("unmarked request should remain valid");
assert!(matches!(decision.lane, PrivacyLane::Standard));
}

#[test]
fn deterministic_inspection_fails_closed_on_opaque_content() {
#[tokio::test]
async fn deterministic_inspection_fails_closed_on_opaque_content() {
let mut request = Request::default();
request.llm_request.messages.push(Message {
role: Role::User,
Expand All @@ -238,9 +285,10 @@ mod tests {
}],
});

let decision = PrivacyPolicy::new(false, Some(vec![DeterministicDetector::Email]))
let decision = PrivacyPolicy::new(false, Some(vec![DeterministicDetector::Email]), None)
.expect("static detector patterns should compile")
.decide(&request)
.await
.expect("opaque content should select a lane");
assert!(matches!(decision.lane, PrivacyLane::Restricted));
assert_eq!(decision.reason_code, "opaque_content");
Expand Down
Loading
Loading