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::error::IdValidationError;
13use systemprompt_identifiers::{ClientSessionId, GatewayConversationId};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Role {
17    System,
18    User,
19    Assistant,
20    Tool,
21}
22
23impl Role {
24    pub const fn as_str(self) -> &'static str {
25        match self {
26            Self::System => "system",
27            Self::User => "user",
28            Self::Assistant => "assistant",
29            Self::Tool => "tool",
30        }
31    }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ImageDetail {
36    Auto,
37    Low,
38    High,
39}
40
41impl ImageDetail {
42    pub const fn as_str(self) -> &'static str {
43        match self {
44            Self::Auto => "auto",
45            Self::Low => "low",
46            Self::High => "high",
47        }
48    }
49}
50
51#[derive(Debug, Clone)]
52pub enum ImageSource {
53    Base64 {
54        media_type: String,
55        data: String,
56        detail: Option<ImageDetail>,
57    },
58    Url {
59        url: String,
60        detail: Option<ImageDetail>,
61    },
62}
63
64#[derive(Debug, Clone)]
65pub enum CanonicalContent {
66    Text(String),
67    Image(ImageSource),
68    ToolUse {
69        id: String,
70        name: String,
71        input: Value,
72        // Why: Gemini requires function-call `thoughtSignature` values replayed verbatim.
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 requires the reasoning item ID and encrypted content
86        // replayed verbatim for stateless reasoning continuity.
87        id: Option<String>,
88        encrypted_content: Option<String>,
89    },
90}
91
92#[derive(Debug, Clone)]
93pub struct CanonicalMessage {
94    pub role: Role,
95    pub content: Vec<CanonicalContent>,
96}
97
98#[derive(Debug, Clone)]
99pub struct CanonicalTool {
100    pub name: String,
101    pub description: Option<String>,
102    pub input_schema: Value,
103}
104
105#[derive(Debug, Clone)]
106pub enum CanonicalToolChoice {
107    Auto,
108    Any,
109    None,
110    Required,
111    Tool(String),
112}
113
114#[derive(Debug, Clone, Copy, Default)]
115pub struct ThinkingConfig {
116    pub enabled: bool,
117    pub budget_tokens: Option<u32>,
118}
119
120#[derive(Debug, Clone)]
121pub enum ResponseFormat {
122    JsonObject,
123    JsonSchema {
124        name: String,
125        schema: Value,
126        strict: bool,
127    },
128}
129
130#[derive(
131    Debug,
132    Clone,
133    Copy,
134    PartialEq,
135    Eq,
136    PartialOrd,
137    Ord,
138    serde::Serialize,
139    serde::Deserialize,
140    schemars::JsonSchema,
141)]
142#[serde(rename_all = "snake_case")]
143pub enum ReasoningEffort {
144    Low,
145    Medium,
146    High,
147}
148
149impl ReasoningEffort {
150    pub const fn as_str(self) -> &'static str {
151        match self {
152            Self::Low => "low",
153            Self::Medium => "medium",
154            Self::High => "high",
155        }
156    }
157}
158
159#[derive(Debug, Clone, Default)]
160pub struct SearchConfig {
161    pub max_uses: Option<u32>,
162    pub context_size: Option<String>,
163    pub urls: Vec<String>,
164}
165
166#[derive(Debug, Clone, Default)]
167pub struct CanonicalRequest {
168    pub model: String,
169    pub system: Option<String>,
170    pub messages: Vec<CanonicalMessage>,
171    pub max_tokens: u32,
172    pub temperature: Option<f32>,
173    pub top_p: Option<f32>,
174    pub top_k: Option<i32>,
175    pub stop_sequences: Vec<String>,
176    pub tools: Vec<CanonicalTool>,
177    pub tool_choice: Option<CanonicalToolChoice>,
178    pub stream: bool,
179    pub thinking: Option<ThinkingConfig>,
180    pub metadata: Option<Value>,
181    pub response_format: Option<ResponseFormat>,
182    pub reasoning_effort: Option<ReasoningEffort>,
183    pub search: Option<SearchConfig>,
184    pub code_execution: bool,
185    pub presence_penalty: Option<f32>,
186    pub frequency_penalty: Option<f32>,
187    pub forwarded_surface: ForwardedSurface,
188}
189
190impl CanonicalRequest {
191    pub fn flatten_parts(&self) -> Vec<(String, String)> {
192        let mut parts = Vec::with_capacity(self.messages.len() + self.forwarded_surface.len() + 1);
193        if let Some(sys) = &self.system
194            && !sys.is_empty()
195        {
196            parts.push(("system".to_owned(), sys.clone()));
197        }
198        for (index, msg) in self.messages.iter().enumerate() {
199            let mut out = String::new();
200            for part in &msg.content {
201                flatten_part(&mut out, part);
202            }
203            if !out.is_empty() {
204                parts.push((format!("messages[{index}].{}", msg.role.as_str()), out));
205            }
206        }
207        for leaf in self.forwarded_surface.leaves() {
208            parts.push((format!("forwarded.{}", leaf.path), leaf.value.clone()));
209        }
210        parts
211    }
212
213    pub fn derived_gateway_conversation_id(&self) -> Option<GatewayConversationId> {
214        let first = self.messages.first()?;
215        let mut content = String::new();
216        for part in &first.content {
217            flatten_part(&mut content, part);
218        }
219        let hash = conversation_prefix_hash(self.system.as_deref(), first.role.as_str(), &content);
220        Some(GatewayConversationId::from_prefix_hash(hash))
221    }
222
223    // Why: the caller's own session travels inside `metadata.user_id`; it is
224    // read here, before the identity is stripped for the upstream.
225    pub fn client_session_id(&self) -> Result<Option<ClientSessionId>, IdValidationError> {
226        let Some(value) = self.metadata.as_ref().and_then(|m| m.get("user_id")) else {
227            return Ok(None);
228        };
229        let value = value.as_str().ok_or_else(|| IdValidationError::Invalid {
230            id_type: "ClientSessionId",
231            message: "metadata.user_id must be a string".to_owned(),
232        })?;
233        ClientSessionId::from_metadata_user_id(value)
234    }
235
236    pub fn flatten_message_text(&self, role: Role) -> Option<String> {
237        let mut out = String::new();
238        for msg in &self.messages {
239            if msg.role != role {
240                continue;
241            }
242            for part in &msg.content {
243                flatten_part(&mut out, part);
244            }
245        }
246        if out.is_empty() { None } else { Some(out) }
247    }
248
249    pub fn latest_message_text(&self, role: Role) -> Option<String> {
250        let msg = self.messages.iter().rev().find(|m| m.role == role)?;
251        let mut out = String::new();
252        for part in &msg.content {
253            flatten_part(&mut out, part);
254        }
255        if out.is_empty() { None } else { Some(out) }
256    }
257
258    pub fn message_units(&self) -> Vec<String> {
259        let mut units = Vec::with_capacity(self.messages.len() + self.forwarded_surface.len() + 1);
260        if let Some(sys) = &self.system {
261            units.push(sys.clone());
262        }
263        for msg in &self.messages {
264            let mut out = String::new();
265            for part in &msg.content {
266                flatten_part(&mut out, part);
267            }
268            if !out.is_empty() {
269                units.push(out);
270            }
271        }
272        for leaf in self.forwarded_surface.leaves() {
273            units.push(leaf.value.clone());
274        }
275        units
276    }
277}
278
279pub(super) fn flatten_part(out: &mut String, part: &CanonicalContent) {
280    match part {
281        CanonicalContent::Text(t) => push_with_sep(out, t),
282        CanonicalContent::Thinking { text, .. } => push_with_sep(out, text),
283        CanonicalContent::ToolUse { name, input, .. } => {
284            push_with_sep(out, &format!("[tool_use:{name} {input}]"));
285        },
286        CanonicalContent::ToolResult { content, .. } => {
287            for inner in content {
288                flatten_part(out, inner);
289            }
290        },
291        CanonicalContent::Image(_) => {},
292    }
293}
294
295fn push_with_sep(out: &mut String, fragment: &str) {
296    if fragment.is_empty() {
297        return;
298    }
299    if !out.is_empty() {
300        out.push('\n');
301    }
302    out.push_str(fragment);
303}