Skip to main content

guardrail_core/
request.rs

1//! Request and response types that flow through the guardrail pipeline.
2
3use serde::{Deserialize, Serialize};
4use std::time::SystemTime;
5use uuid::Uuid;
6
7/// The normalized, provider-agnostic representation of an LLM request.
8///
9/// Constructed from a raw HTTP body regardless of provider (OpenAI, Anthropic, etc.).
10/// This is the primary type passed through every pipeline stage.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct GuardrailRequest {
13    /// Unique ID for this request, generated at ingress.
14    pub id: Uuid,
15    /// Wall-clock time the request arrived at the proxy.
16    #[serde(with = "system_time_serde")]
17    pub received_at: SystemTime,
18    /// The full message list, including system prompt.
19    pub messages: Vec<ChatMessage>,
20    /// Original provider (parsed from Host header or config).
21    pub provider: Provider,
22    /// Model identifier as sent by the caller.
23    pub model: String,
24    /// Whether the caller requested streaming.
25    pub stream: bool,
26    /// All other fields from the original payload, preserved verbatim.
27    pub extra: serde_json::Value,
28}
29
30impl GuardrailRequest {
31    /// Create a new request with the given messages and model.
32    ///
33    /// # Examples
34    ///
35    /// ```rust
36    /// use guardrail_core::request::{GuardrailRequest, ChatMessage, Role, MessageContent, Provider};
37    ///
38    /// let req = GuardrailRequest::new(
39    ///     vec![ChatMessage { role: Role::User, content: MessageContent::Text("Hello".into()) }],
40    ///     "gpt-4o".into(),
41    ///     Provider::OpenAI,
42    /// );
43    /// assert_eq!(req.model, "gpt-4o");
44    /// ```
45    pub fn new(messages: Vec<ChatMessage>, model: String, provider: Provider) -> Self {
46        Self {
47            id: Uuid::new_v4(),
48            received_at: SystemTime::now(),
49            messages,
50            provider,
51            model,
52            stream: false,
53            extra: serde_json::Value::Null,
54        }
55    }
56
57    /// Extract all text content from user-role messages as a single string.
58    ///
59    /// Useful for classifiers that operate on the combined user input.
60    pub fn user_text(&self) -> String {
61        self.messages
62            .iter()
63            .filter(|m| matches!(m.role, Role::User))
64            .map(|m| m.content.as_text())
65            .collect::<Vec<_>>()
66            .join("\n")
67    }
68
69    /// Extract the text of every message whose role is in `roles`, joined
70    /// with newlines, in message order.
71    ///
72    /// # Examples
73    ///
74    /// ```rust
75    /// use guardrail_core::request::{ChatMessage, GuardrailRequest, MessageContent, Provider, Role};
76    ///
77    /// let req = GuardrailRequest::new(
78    ///     vec![
79    ///         ChatMessage { role: Role::System, content: MessageContent::Text("be nice".into()) },
80    ///         ChatMessage { role: Role::User, content: MessageContent::Text("hi".into()) },
81    ///     ],
82    ///     "gpt-4o".into(),
83    ///     Provider::OpenAI,
84    /// );
85    /// assert_eq!(req.text_for_roles(&[Role::User]), "hi");
86    /// assert_eq!(req.text_for_roles(&[Role::System, Role::User]), "be nice\nhi");
87    /// ```
88    pub fn text_for_roles(&self, roles: &[Role]) -> String {
89        self.messages
90            .iter()
91            .filter(|m| roles.contains(&m.role))
92            .map(|m| m.content.as_text())
93            .collect::<Vec<_>>()
94            .join("\n")
95    }
96
97    /// Extract all text content from all messages.
98    pub fn all_text(&self) -> String {
99        self.messages
100            .iter()
101            .map(|m| m.content.as_text())
102            .collect::<Vec<_>>()
103            .join("\n")
104    }
105}
106
107/// A single message in the conversation.
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109pub struct ChatMessage {
110    /// The role of the message author.
111    pub role: Role,
112    /// The message content (text or multi-part).
113    pub content: MessageContent,
114}
115
116/// The content of a message — either plain text or a list of content parts.
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118#[serde(untagged)]
119pub enum MessageContent {
120    /// Plain text content (most common).
121    Text(String),
122    /// Multi-part content (text + images).
123    Parts(Vec<ContentPart>),
124}
125
126impl MessageContent {
127    /// Extract all text from the content as a single string.
128    pub fn as_text(&self) -> String {
129        match self {
130            MessageContent::Text(t) => t.clone(),
131            MessageContent::Parts(parts) => parts
132                .iter()
133                .filter_map(|p| {
134                    if let ContentPart::Text { text } = p {
135                        Some(text.as_str())
136                    } else {
137                        None
138                    }
139                })
140                .collect::<Vec<_>>()
141                .join("\n"),
142        }
143    }
144}
145
146/// A single content part in a multi-part message.
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
148#[serde(tag = "type", rename_all = "snake_case")]
149pub enum ContentPart {
150    /// Plain text part.
151    Text {
152        /// The text content.
153        text: String,
154    },
155    /// Image URL part.
156    ImageUrl {
157        /// The image URL details.
158        image_url: ImageUrl,
159    },
160}
161
162/// An image referenced by URL.
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
164pub struct ImageUrl {
165    /// The URL of the image.
166    pub url: String,
167    /// The detail level (`"low"`, `"high"`, `"auto"`).
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub detail: Option<String>,
170}
171
172/// The role of a message author.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "lowercase")]
175pub enum Role {
176    /// System-level instruction.
177    System,
178    /// End-user turn.
179    User,
180    /// Model turn.
181    Assistant,
182    /// Tool / function call result.
183    Tool,
184}
185
186/// The upstream LLM provider.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "lowercase")]
189pub enum Provider {
190    /// OpenAI (api.openai.com).
191    OpenAI,
192    /// Anthropic (api.anthropic.com).
193    Anthropic,
194    /// Azure OpenAI Service.
195    Azure,
196    /// Any other provider (e.g., Ollama, vLLM).
197    Other(String),
198}
199
200/// Serde helper for `SystemTime`.
201mod system_time_serde {
202    use serde::{Deserialize, Deserializer, Serialize, Serializer};
203    use std::time::{SystemTime, UNIX_EPOCH};
204
205    pub fn serialize<S: Serializer>(t: &SystemTime, s: S) -> Result<S::Ok, S::Error> {
206        let secs = t
207            .duration_since(UNIX_EPOCH)
208            .unwrap_or_default()
209            .as_secs_f64();
210        secs.serialize(s)
211    }
212
213    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SystemTime, D::Error> {
214        let secs = f64::deserialize(d)?;
215        Ok(UNIX_EPOCH + std::time::Duration::from_secs_f64(secs))
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_user_text_extraction() {
225        let req = GuardrailRequest::new(
226            vec![
227                ChatMessage {
228                    role: Role::System,
229                    content: MessageContent::Text("You are a helpful assistant.".into()),
230                },
231                ChatMessage {
232                    role: Role::User,
233                    content: MessageContent::Text("Hello!".into()),
234                },
235            ],
236            "gpt-4o".into(),
237            Provider::OpenAI,
238        );
239
240        let text = req.user_text();
241        assert_eq!(text, "Hello!");
242    }
243
244    #[test]
245    fn test_message_content_as_text_parts() {
246        let content = MessageContent::Parts(vec![
247            ContentPart::Text {
248                text: "Hello".into(),
249            },
250            ContentPart::ImageUrl {
251                image_url: ImageUrl {
252                    url: "https://example.com/img.png".into(),
253                    detail: None,
254                },
255            },
256            ContentPart::Text {
257                text: "World".into(),
258            },
259        ]);
260        assert_eq!(content.as_text(), "Hello\nWorld");
261    }
262
263    #[test]
264    fn test_request_roundtrip_json() {
265        let req = GuardrailRequest::new(
266            vec![ChatMessage {
267                role: Role::User,
268                content: MessageContent::Text("Test".into()),
269            }],
270            "gpt-4o".into(),
271            Provider::OpenAI,
272        );
273        let json = serde_json::to_string(&req).unwrap();
274        let decoded: GuardrailRequest = serde_json::from_str(&json).unwrap();
275        assert_eq!(decoded.model, req.model);
276        assert_eq!(decoded.id, req.id);
277    }
278}