use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::common::ValidationError;
use crate::context_gateway::{ClassificationV1, ContextDecisionReceiptV1};
pub const ENGINE_EGRESS_SCHEMA_VERSION: u32 = 1;
pub const MAX_ENGINE_EGRESS_REQUEST_BYTES: usize = 32 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EngineEgressAdmissionRequestV1 {
pub schema_version: u32,
pub provider: String,
pub upstream_base: String,
pub body: Value,
}
impl EngineEgressAdmissionRequestV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.schema_version != ENGINE_EGRESS_SCHEMA_VERSION {
return Err(ValidationError::new("unsupported egress schema version"));
}
if self.provider.trim().is_empty() || self.provider.len() > 64 {
return Err(ValidationError::new("egress provider label is invalid"));
}
if self.upstream_base.trim().is_empty() || self.upstream_base.len() > 2048 {
return Err(ValidationError::new("egress upstream base is invalid"));
}
if !self.body.is_object() {
return Err(ValidationError::new("egress body must be a JSON object"));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EgressDispositionV1 {
Forward,
Rewritten,
Refused,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EngineEgressAdmissionResponseV1 {
pub schema_version: u32,
pub disposition: EgressDispositionV1,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refusal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub classification: Option<ClassificationV1>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub receipt: Option<ContextDecisionReceiptV1>,
}
impl EngineEgressAdmissionResponseV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.schema_version != ENGINE_EGRESS_SCHEMA_VERSION {
return Err(ValidationError::new("unsupported egress schema version"));
}
let consistent = match self.disposition {
EgressDispositionV1::Refused => self.body.is_none() && self.refusal.is_some(),
EgressDispositionV1::Forward | EgressDispositionV1::Rewritten => {
self.body.is_some() && self.refusal.is_none()
}
};
if !consistent {
return Err(ValidationError::new(
"egress body and refusal must match the disposition",
));
}
if let Some(receipt) = &self.receipt {
receipt.validate()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_refusal_never_carries_a_body_and_a_forward_always_does() {
let mut response = EngineEgressAdmissionResponseV1 {
schema_version: ENGINE_EGRESS_SCHEMA_VERSION,
disposition: EgressDispositionV1::Refused,
body: None,
refusal: Some("request withheld by the context gateway (x)".into()),
classification: Some(ClassificationV1::Restricted),
receipt: None,
};
assert!(response.validate().is_ok());
response.body = Some(serde_json::json!({"model": "m"}));
assert!(response.validate().is_err(), "refused with a body");
response.disposition = EgressDispositionV1::Forward;
response.refusal = None;
assert!(response.validate().is_ok());
response.body = None;
assert!(response.validate().is_err(), "forward without a body");
}
#[test]
fn requests_need_an_object_body_and_the_current_schema() {
let request = EngineEgressAdmissionRequestV1 {
schema_version: ENGINE_EGRESS_SCHEMA_VERSION,
provider: "openai".into(),
upstream_base: "https://api.openai.com".into(),
body: serde_json::json!({"model": "m", "messages": []}),
};
assert!(request.validate().is_ok());
let mut array = request.clone();
array.body = serde_json::json!([]);
assert!(array.validate().is_err());
let mut future = request;
future.schema_version = 2;
assert!(future.validate().is_err());
}
}