lean_ctx_protocol/
engine_egress.rs1use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use crate::common::ValidationError;
16use crate::context_gateway::{ClassificationV1, ContextDecisionReceiptV1};
17
18pub const ENGINE_EGRESS_SCHEMA_VERSION: u32 = 1;
19pub const MAX_ENGINE_EGRESS_REQUEST_BYTES: usize = 32 * 1024 * 1024;
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct EngineEgressAdmissionRequestV1 {
25 pub schema_version: u32,
26 pub provider: String,
29 pub upstream_base: String,
32 pub body: Value,
34}
35
36impl EngineEgressAdmissionRequestV1 {
37 pub fn validate(&self) -> Result<(), ValidationError> {
38 if self.schema_version != ENGINE_EGRESS_SCHEMA_VERSION {
39 return Err(ValidationError::new("unsupported egress schema version"));
40 }
41 if self.provider.trim().is_empty() || self.provider.len() > 64 {
42 return Err(ValidationError::new("egress provider label is invalid"));
43 }
44 if self.upstream_base.trim().is_empty() || self.upstream_base.len() > 2048 {
45 return Err(ValidationError::new("egress upstream base is invalid"));
46 }
47 if !self.body.is_object() {
48 return Err(ValidationError::new("egress body must be a JSON object"));
49 }
50 Ok(())
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum EgressDispositionV1 {
57 Forward,
59 Rewritten,
61 Refused,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct EngineEgressAdmissionResponseV1 {
68 pub schema_version: u32,
69 pub disposition: EgressDispositionV1,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub body: Option<Value>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub refusal: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub classification: Option<ClassificationV1>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub receipt: Option<ContextDecisionReceiptV1>,
84}
85
86impl EngineEgressAdmissionResponseV1 {
87 pub fn validate(&self) -> Result<(), ValidationError> {
88 if self.schema_version != ENGINE_EGRESS_SCHEMA_VERSION {
89 return Err(ValidationError::new("unsupported egress schema version"));
90 }
91 let consistent = match self.disposition {
92 EgressDispositionV1::Refused => self.body.is_none() && self.refusal.is_some(),
93 EgressDispositionV1::Forward | EgressDispositionV1::Rewritten => {
94 self.body.is_some() && self.refusal.is_none()
95 }
96 };
97 if !consistent {
98 return Err(ValidationError::new(
99 "egress body and refusal must match the disposition",
100 ));
101 }
102 if let Some(receipt) = &self.receipt {
103 receipt.validate()?;
104 }
105 Ok(())
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn a_refusal_never_carries_a_body_and_a_forward_always_does() {
115 let mut response = EngineEgressAdmissionResponseV1 {
116 schema_version: ENGINE_EGRESS_SCHEMA_VERSION,
117 disposition: EgressDispositionV1::Refused,
118 body: None,
119 refusal: Some("request withheld by the context gateway (x)".into()),
120 classification: Some(ClassificationV1::Restricted),
121 receipt: None,
122 };
123 assert!(response.validate().is_ok());
124 response.body = Some(serde_json::json!({"model": "m"}));
125 assert!(response.validate().is_err(), "refused with a body");
126 response.disposition = EgressDispositionV1::Forward;
127 response.refusal = None;
128 assert!(response.validate().is_ok());
129 response.body = None;
130 assert!(response.validate().is_err(), "forward without a body");
131 }
132
133 #[test]
134 fn requests_need_an_object_body_and_the_current_schema() {
135 let request = EngineEgressAdmissionRequestV1 {
136 schema_version: ENGINE_EGRESS_SCHEMA_VERSION,
137 provider: "openai".into(),
138 upstream_base: "https://api.openai.com".into(),
139 body: serde_json::json!({"model": "m", "messages": []}),
140 };
141 assert!(request.validate().is_ok());
142 let mut array = request.clone();
143 array.body = serde_json::json!([]);
144 assert!(array.validate().is_err());
145 let mut future = request;
146 future.schema_version = 2;
147 assert!(future.validate().is_err());
148 }
149}