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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/switchyard-runner/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ prefill-router = ["dep:prefill-router"]
libsy = { package = "switchyard-libsy", path = "../libsy", version = "0.3.0" }
prefill-router = { workspace = true, optional = true }
reqwest.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
switchyard-llm-client.workspace = true
Expand Down
81 changes: 78 additions & 3 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::PrivacyPolicy;
use crate::privacy::{DeterministicDetector, PrivacyPolicy};
use crate::route::ExecutionLane;
use crate::{
AlgorithmSpec, AuxiliaryTarget, CallerAuthKind, DecisionTarget, ModelCapabilities, Route,
Expand Down Expand Up @@ -85,10 +85,29 @@ struct RouteConfig {
struct PrivacyConfig {
restricted_targets: BTreeMap<String, String>,
restricted_decision_target: Option<String>,
deterministic: Option<DeterministicConfig>,
#[serde(default)]
accept_external_signal: bool,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct DeterministicConfig {
#[serde(default)]
detectors: BTreeSet<DeterministicDetector>,
}

impl DeterministicConfig {
fn build(&self, route_name: &str) -> RunnerResult<Vec<DeterministicDetector>> {
if self.detectors.is_empty() {
return Err(RunnerError::configuration(format!(
"route {route_name} privacy deterministic must configure at least one detector"
)));
}
Ok(self.detectors.iter().copied().collect())
}
}

struct TargetPromptPolicy {
prompts: HashMap<ModelId, String>,
routing_answer_target: Option<ModelId>,
Expand Down Expand Up @@ -327,11 +346,16 @@ impl DeploymentConfig {
"route {route_name} cannot use privacy with prefill_router"
)));
}
if !config.accept_external_signal {
if !config.accept_external_signal && config.deterministic.is_none() {
return Err(RunnerError::configuration(format!(
"route {route_name} privacy must configure at least one request input"
)));
}
let detectors = config
.deterministic
.as_ref()
.map(|config| config.build(route_name))
.transpose()?;
let restricted_targets =
self.resolve_lane_targets(route_name, route, Some(&config.restricted_targets))?;
let restricted_decision = match (
Expand Down Expand Up @@ -366,7 +390,14 @@ impl DeploymentConfig {
clients,
)?;
Ok(Some(BuiltPrivacy {
policy: PrivacyPolicy::new(config.accept_external_signal),
policy: PrivacyPolicy::new(config.accept_external_signal, detectors).map_err(
|error| {
RunnerError::configuration_source(
format!("route {route_name} privacy detectors could not be compiled"),
error,
)
},
)?,
restricted,
}))
}
Expand Down Expand Up @@ -1135,6 +1166,7 @@ bogus = true
mod deployment_tests {
use super::*;
use serde_json::json;
use switchyard_protocol::{Message, Request, Role};

const VALID_CONFIG: &str = r#"
schema_version = 1
Expand Down Expand Up @@ -1234,6 +1266,19 @@ weak = "weak"
)
}

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

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

[routes.passthrough.privacy.deterministic]
detectors = ["bearer_token"]
"#
)
}

#[test]
fn public_runner_from_toml_builds_a_deployment() -> RunnerResult<()> {
let runner = Runner::from_toml(VALID_CONFIG)?;
Expand Down Expand Up @@ -1303,6 +1348,36 @@ weak = "weak"
Ok(())
}

#[tokio::test]
async fn deterministic_privacy_selects_the_restricted_lane() -> RunnerResult<()> {
let configured = deterministic_privacy_config();
let runner = Runner::from_toml(&configured)?;
let route = runner
.route("switchyard/passthrough")
.expect("privacy route should exist");

assert_eq!(
route
.decide(Request::default())
.await?
.selected_model_id()?,
"weak/model"
);
let mut request = Request::default();
request.llm_request.messages.push(Message::text(
Role::User,
"Authorization: Bearer abcdefghijklmnop",
));
assert_eq!(
route.decide(request).await?.selected_model_id()?,
"strong/model"
);

let empty = configured.replace("detectors = [\"bearer_token\"]", "detectors = []");
assert!(error_message(&empty).contains("must configure at least one detector"));
Ok(())
}

#[test]
fn duplicate_route_ids_are_rejected() {
let config = format!(
Expand Down
111 changes: 87 additions & 24 deletions crates/switchyard-runner/src/privacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,21 @@

//! Privacy policy used to select a route's execution lane.

mod deterministic;

use serde_json::Value;
use strum_macros::{EnumString, IntoStaticStr};
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) 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>,
}

/// Target set allowed to serve one request.
Expand Down Expand Up @@ -60,6 +65,7 @@ pub(crate) struct PrivacyDecision {
pub(crate) enum PrivacySource {
Policy,
ExternalSignal,
Deterministic,
}

impl PrivacySource {
Expand All @@ -82,28 +88,6 @@ impl PrivacyDecision {
}
}

impl PrivacyPolicy {
pub(crate) const fn new(accept_external_signal: bool) -> Self {
Self {
accept_external_signal,
}
}

pub(crate) fn decide(&self, request: &Request) -> Result<PrivacyDecision, LlmClientError> {
if !has_external_restriction(request) {
return Ok(PrivacyDecision::all_clear());
}
if !self.accept_external_signal {
return Err(external_signal_not_accepted());
}
Ok(PrivacyDecision::new(
PrivacyLane::Restricted,
PrivacySource::ExternalSignal,
"restricted",
))
}
}

/// Marks a request as requiring a route that accepts external privacy signals.
/// Execution fails if the selected route has not enabled `accept_external_signal`.
/// This marker is available only to trusted in-process hosts, not HTTP clients.
Expand All @@ -130,6 +114,44 @@ pub(crate) fn external_signal_not_accepted() -> LlmClientError {
}
}

impl PrivacyPolicy {
pub(crate) fn new(
accept_external_signal: bool,
detectors: Option<Vec<DeterministicDetector>>,
) -> Result<Self, regex::Error> {
Ok(Self {
accept_external_signal,
inspector: detectors.map(Inspector::new).transpose()?,
})
}

pub(crate) fn decide(&self, request: &Request) -> Result<PrivacyDecision, LlmClientError> {
if has_external_restriction(request) {
if !self.accept_external_signal {
return Err(external_signal_not_accepted());
}
return Ok(PrivacyDecision::new(
PrivacyLane::Restricted,
PrivacySource::ExternalSignal,
"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,
)
}
Assessment::Clear => PrivacyDecision::all_clear(),
})
}
}

pub(crate) fn validate_mixed_request(request: &Request) -> Result<(), LlmClientError> {
let extensions = &request.llm_request.extensions.fields;
let preserved = &request.llm_request.preservation.requests;
Expand Down Expand Up @@ -167,20 +189,61 @@ fn has_responses_state(body: &Value) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use switchyard_protocol::{ContentBlock, Message, Role};

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

assert!(PrivacyPolicy::new(false).decide(&request).is_err());
assert!(
PrivacyPolicy::new(false, None)
.expect("empty detector configuration should compile")
.decide(&request)
.is_err()
);
assert!(matches!(
PrivacyPolicy::new(true).decide(&request),
PrivacyPolicy::new(true, None)
.expect("empty detector configuration should compile")
.decide(&request),
Ok(PrivacyDecision {
lane: PrivacyLane::Restricted,
..
})
));

let mut opaque = Request::default();
opaque.llm_request.messages.push(Message {
role: Role::User,
content: vec![ContentBlock::Unknown {
provider: "custom".into(),
raw: Value::Null,
}],
});
let decision = PrivacyPolicy::new(true, None)
.expect("empty detector configuration should compile")
.decide(&opaque)
.expect("unmarked request should remain valid");
assert!(matches!(decision.lane, PrivacyLane::Standard));
}

#[test]
fn deterministic_inspection_fails_closed_on_opaque_content() {
let mut request = Request::default();
request.llm_request.messages.push(Message {
role: Role::User,
content: vec![ContentBlock::Unknown {
provider: "custom".into(),
raw: Value::Null,
}],
});

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

#[test]
Expand Down
Loading
Loading