Skip to main content

systemprompt_models/wire/canonical/
request.rs

1//! The provider-neutral request model the gateway translates to and from.
2//!
3//! The flattening helpers derive plain-text views and a stable
4//! [`GatewayConversationId`] from the leading message.
5//!
6//! Copyright (c) systemprompt.io — Business Source License 1.1.
7//! See <https://systemprompt.io> for licensing details.
8
9use crate::gateway_hash::conversation_prefix_hash;
10use crate::wire::inspect::ForwardedSurface;
11use serde_json::Value;
12use systemprompt_identifiers::GatewayConversationId;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Role {
16    System,
17    User,
18    Assistant,
19    Tool,
20}
21
22impl Role {
23    pub const fn as_str(self) -> &'static str {
24        match self {
25            Self::System => "system",
26            Self::User => "user",
27            Self::Assistant => "assistant",
28            Self::Tool => "tool",
29        }
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ImageDetail {
35    Auto,
36    Low,
37    High,
38}
39
40impl ImageDetail {
41    pub const fn as_str(self) -> &'static str {
42        match self {
43            Self::Auto => "auto",
44            Self::Low => "low",
45            Self::High => "high",
46        }
47    }
48}
49
50#[derive(Debug, Clone)]
51pub enum ImageSource {
52    Base64 {
53        media_type: String,
54        data: String,
55        detail: Option<ImageDetail>,
56    },
57    Url {
58        url: String,
59        detail: Option<ImageDetail>,
60    },
61}
62
63#[derive(Debug, Clone)]
64pub enum CanonicalContent {
65    Text(String),
66    Image(ImageSource),
67    ToolUse {
68        id: String,
69        name: String,
70        input: Value,
71        // Why: Gemini attaches an opaque `thoughtSignature` to function-call parts that
72        // must be echoed back verbatim on the next turn; this carries it through.
73        signature: Option<String>,
74    },
75    ToolResult {
76        tool_use_id: String,
77        content: Vec<Self>,
78        is_error: bool,
79        structured_content: Option<Value>,
80        meta: Option<Value>,
81    },
82    Thinking {
83        text: String,
84        signature: Option<String>,
85        // Why: OpenAI Responses reasoning items carry a provider id and, with
86        // `include: ["reasoning.encrypted_content"]`, an opaque blob — both must
87        // be replayed verbatim for stateless reasoning continuity, exactly like
88        // Gemini's thoughtSignature. Anthropic/Gemini thinking has neither.
89        id: Option<String>,
90        encrypted_content: Option<String>,
91    },
92}
93
94#[derive(Debug, Clone)]
95pub struct CanonicalMessage {
96    pub role: Role,
97    pub content: Vec<CanonicalContent>,
98}
99
100#[derive(Debug, Clone)]
101pub struct CanonicalTool {
102    pub name: String,
103    pub description: Option<String>,
104    pub input_schema: Value,
105}
106
107#[derive(Debug, Clone)]
108pub enum CanonicalToolChoice {
109    Auto,
110    Any,
111    None,
112    Required,
113    Tool(String),
114}
115
116#[derive(Debug, Clone, Copy, Default)]
117pub struct ThinkingConfig {
118    pub enabled: bool,
119    pub budget_tokens: Option<u32>,
120}
121
122#[derive(Debug, Clone)]
123pub enum ResponseFormat {
124    JsonObject,
125    JsonSchema {
126        name: String,
127        schema: Value,
128        strict: bool,
129    },
130}
131
132#[derive(
133    Debug,
134    Clone,
135    Copy,
136    PartialEq,
137    Eq,
138    PartialOrd,
139    Ord,
140    serde::Serialize,
141    serde::Deserialize,
142    schemars::JsonSchema,
143)]
144#[serde(rename_all = "snake_case")]
145pub enum ReasoningEffort {
146    Low,
147    Medium,
148    High,
149}
150
151impl ReasoningEffort {
152    pub const fn as_str(self) -> &'static str {
153        match self {
154            Self::Low => "low",
155            Self::Medium => "medium",
156            Self::High => "high",
157        }
158    }
159}
160
161#[derive(Debug, Clone, Default)]
162pub struct SearchConfig {
163    pub max_uses: Option<u32>,
164    pub context_size: Option<String>,
165    pub urls: Vec<String>,
166}
167
168#[derive(Debug, Clone, Default)]
169pub struct CanonicalRequest {
170    pub model: String,
171    pub system: Option<String>,
172    pub messages: Vec<CanonicalMessage>,
173    pub max_tokens: u32,
174    pub temperature: Option<f32>,
175    pub top_p: Option<f32>,
176    pub top_k: Option<i32>,
177    pub stop_sequences: Vec<String>,
178    pub tools: Vec<CanonicalTool>,
179    pub tool_choice: Option<CanonicalToolChoice>,
180    pub stream: bool,
181    pub thinking: Option<ThinkingConfig>,
182    pub metadata: Option<Value>,
183    pub response_format: Option<ResponseFormat>,
184    pub reasoning_effort: Option<ReasoningEffort>,
185    pub search: Option<SearchConfig>,
186    pub code_execution: bool,
187    pub presence_penalty: Option<f32>,
188    pub frequency_penalty: Option<f32>,
189    /// Every string in the bytes the gateway is about to forward upstream.
190    ///
191    /// The canonical form is lossy, so content the gateway relays can be
192    /// invisible to a scanner reading only that form. The gateway fills this
193    /// from the prepared outbound body before any scan runs, which is what
194    /// makes the scan surface a superset of the forwarded surface. Callers
195    /// that never send through the gateway leave it empty.
196    pub forwarded_surface: ForwardedSurface,
197}
198
199impl CanonicalRequest {
200    /// The broadest inspection surface a scanner can ask for, and the one a
201    /// blocking scanner should use: it covers content the canonical model
202    /// cannot represent but the provider will still receive.
203    pub fn flatten_text(&self) -> String {
204        let mut out = String::new();
205        if let Some(sys) = &self.system {
206            push_with_sep(&mut out, sys);
207        }
208        for msg in &self.messages {
209            for part in &msg.content {
210                flatten_part(&mut out, part);
211            }
212        }
213        for leaf in self.forwarded_surface.leaves() {
214            push_with_sep(&mut out, &leaf.value);
215        }
216        out
217    }
218
219    pub fn derived_gateway_conversation_id(&self) -> Option<GatewayConversationId> {
220        let first = self.messages.first()?;
221        let mut content = String::new();
222        for part in &first.content {
223            flatten_part(&mut content, part);
224        }
225        let hash = conversation_prefix_hash(self.system.as_deref(), first.role.as_str(), &content);
226        Some(GatewayConversationId::from_prefix_hash(hash))
227    }
228
229    pub fn flatten_message_text(&self, role: Role) -> Option<String> {
230        let mut out = String::new();
231        for msg in &self.messages {
232            if msg.role != role {
233                continue;
234            }
235            for part in &msg.content {
236                flatten_part(&mut out, part);
237            }
238        }
239        if out.is_empty() { None } else { Some(out) }
240    }
241
242    /// Flattens the newest message of `role`, ignoring every earlier one.
243    ///
244    /// Distinct from [`Self::flatten_message_text`], which concatenates every
245    /// message of the role: a safety scanner that judges the latter re-reads
246    /// what the caller already sent on previous turns.
247    pub fn latest_message_text(&self, role: Role) -> Option<String> {
248        let msg = self.messages.iter().rev().find(|m| m.role == role)?;
249        let mut out = String::new();
250        for part in &msg.content {
251            flatten_part(&mut out, part);
252        }
253        if out.is_empty() { None } else { Some(out) }
254    }
255
256    /// Detectors that slide a window over their input need this rather than
257    /// [`Self::flatten_text`], whose concatenation lets two unrelated messages
258    /// splice into a match neither one contains. Each surface leaf is its own
259    /// unit for the same reason.
260    pub fn message_units(&self) -> Vec<String> {
261        let mut units = Vec::with_capacity(self.messages.len() + self.forwarded_surface.len() + 1);
262        if let Some(sys) = &self.system {
263            units.push(sys.clone());
264        }
265        for msg in &self.messages {
266            let mut out = String::new();
267            for part in &msg.content {
268                flatten_part(&mut out, part);
269            }
270            if !out.is_empty() {
271                units.push(out);
272            }
273        }
274        for leaf in self.forwarded_surface.leaves() {
275            units.push(leaf.value.clone());
276        }
277        units
278    }
279}
280
281pub(super) fn flatten_part(out: &mut String, part: &CanonicalContent) {
282    match part {
283        CanonicalContent::Text(t) => push_with_sep(out, t),
284        CanonicalContent::Thinking { text, .. } => push_with_sep(out, text),
285        CanonicalContent::ToolUse { name, input, .. } => {
286            push_with_sep(out, &format!("[tool_use:{name} {input}]"));
287        },
288        CanonicalContent::ToolResult { content, .. } => {
289            for inner in content {
290                flatten_part(out, inner);
291            }
292        },
293        CanonicalContent::Image(_) => {},
294    }
295}
296
297fn push_with_sep(out: &mut String, fragment: &str) {
298    if fragment.is_empty() {
299        return;
300    }
301    if !out.is_empty() {
302        out.push('\n');
303    }
304    out.push_str(fragment);
305}