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::{ClientSessionId, 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 requires function-call `thoughtSignature` values replayed verbatim.
72        signature: Option<String>,
73    },
74    ToolResult {
75        tool_use_id: String,
76        content: Vec<Self>,
77        is_error: bool,
78        structured_content: Option<Value>,
79        meta: Option<Value>,
80    },
81    Thinking {
82        text: String,
83        signature: Option<String>,
84        // Why: OpenAI Responses requires the reasoning item ID and encrypted content
85        // replayed verbatim for stateless reasoning continuity.
86        id: Option<String>,
87        encrypted_content: Option<String>,
88    },
89}
90
91#[derive(Debug, Clone)]
92pub struct CanonicalMessage {
93    pub role: Role,
94    pub content: Vec<CanonicalContent>,
95}
96
97#[derive(Debug, Clone)]
98pub struct CanonicalTool {
99    pub name: String,
100    pub description: Option<String>,
101    pub input_schema: Value,
102}
103
104#[derive(Debug, Clone)]
105pub enum CanonicalToolChoice {
106    Auto,
107    Any,
108    None,
109    Required,
110    Tool(String),
111}
112
113#[derive(Debug, Clone, Copy, Default)]
114pub struct ThinkingConfig {
115    pub enabled: bool,
116    pub budget_tokens: Option<u32>,
117}
118
119#[derive(Debug, Clone)]
120pub enum ResponseFormat {
121    JsonObject,
122    JsonSchema {
123        name: String,
124        schema: Value,
125        strict: bool,
126    },
127}
128
129#[derive(
130    Debug,
131    Clone,
132    Copy,
133    PartialEq,
134    Eq,
135    PartialOrd,
136    Ord,
137    serde::Serialize,
138    serde::Deserialize,
139    schemars::JsonSchema,
140)]
141#[serde(rename_all = "snake_case")]
142pub enum ReasoningEffort {
143    Low,
144    Medium,
145    High,
146}
147
148impl ReasoningEffort {
149    pub const fn as_str(self) -> &'static str {
150        match self {
151            Self::Low => "low",
152            Self::Medium => "medium",
153            Self::High => "high",
154        }
155    }
156}
157
158#[derive(Debug, Clone, Default)]
159pub struct SearchConfig {
160    pub max_uses: Option<u32>,
161    pub context_size: Option<String>,
162    pub urls: Vec<String>,
163}
164
165#[derive(Debug, Clone, Default)]
166pub struct CanonicalRequest {
167    pub model: String,
168    pub system: Option<String>,
169    pub messages: Vec<CanonicalMessage>,
170    pub max_tokens: u32,
171    pub temperature: Option<f32>,
172    pub top_p: Option<f32>,
173    pub top_k: Option<i32>,
174    pub stop_sequences: Vec<String>,
175    pub tools: Vec<CanonicalTool>,
176    pub tool_choice: Option<CanonicalToolChoice>,
177    pub stream: bool,
178    pub thinking: Option<ThinkingConfig>,
179    pub metadata: Option<Value>,
180    pub response_format: Option<ResponseFormat>,
181    pub reasoning_effort: Option<ReasoningEffort>,
182    pub search: Option<SearchConfig>,
183    pub code_execution: bool,
184    pub presence_penalty: Option<f32>,
185    pub frequency_penalty: Option<f32>,
186    pub forwarded_surface: ForwardedSurface,
187}
188
189impl CanonicalRequest {
190    pub fn flatten_parts(&self) -> Vec<(String, String)> {
191        let mut parts = Vec::with_capacity(self.messages.len() + self.forwarded_surface.len() + 1);
192        if let Some(sys) = &self.system
193            && !sys.is_empty()
194        {
195            parts.push(("system".to_owned(), sys.clone()));
196        }
197        for (index, msg) in self.messages.iter().enumerate() {
198            let mut out = String::new();
199            for part in &msg.content {
200                flatten_part(&mut out, part);
201            }
202            if !out.is_empty() {
203                parts.push((format!("messages[{index}].{}", msg.role.as_str()), out));
204            }
205        }
206        for leaf in self.forwarded_surface.leaves() {
207            parts.push((format!("forwarded.{}", leaf.path), leaf.value.clone()));
208        }
209        parts
210    }
211
212    pub fn derived_gateway_conversation_id(&self) -> Option<GatewayConversationId> {
213        let first = self.messages.first()?;
214        let mut content = String::new();
215        for part in &first.content {
216            flatten_part(&mut content, part);
217        }
218        let hash = conversation_prefix_hash(self.system.as_deref(), first.role.as_str(), &content);
219        Some(GatewayConversationId::from_prefix_hash(hash))
220    }
221
222    // Why: the caller's own session travels inside `metadata.user_id`; it is
223    // read here, before the identity is stripped for the upstream.
224    pub fn client_session_id(&self) -> Option<ClientSessionId> {
225        let user_id = self.metadata.as_ref()?.get("user_id")?.as_str()?;
226        ClientSessionId::from_metadata_user_id(user_id)
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    pub fn latest_message_text(&self, role: Role) -> Option<String> {
243        let msg = self.messages.iter().rev().find(|m| m.role == role)?;
244        let mut out = String::new();
245        for part in &msg.content {
246            flatten_part(&mut out, part);
247        }
248        if out.is_empty() { None } else { Some(out) }
249    }
250
251    pub fn message_units(&self) -> Vec<String> {
252        let mut units = Vec::with_capacity(self.messages.len() + self.forwarded_surface.len() + 1);
253        if let Some(sys) = &self.system {
254            units.push(sys.clone());
255        }
256        for msg in &self.messages {
257            let mut out = String::new();
258            for part in &msg.content {
259                flatten_part(&mut out, part);
260            }
261            if !out.is_empty() {
262                units.push(out);
263            }
264        }
265        for leaf in self.forwarded_surface.leaves() {
266            units.push(leaf.value.clone());
267        }
268        units
269    }
270}
271
272pub(super) fn flatten_part(out: &mut String, part: &CanonicalContent) {
273    match part {
274        CanonicalContent::Text(t) => push_with_sep(out, t),
275        CanonicalContent::Thinking { text, .. } => push_with_sep(out, text),
276        CanonicalContent::ToolUse { name, input, .. } => {
277            push_with_sep(out, &format!("[tool_use:{name} {input}]"));
278        },
279        CanonicalContent::ToolResult { content, .. } => {
280            for inner in content {
281                flatten_part(out, inner);
282            }
283        },
284        CanonicalContent::Image(_) => {},
285    }
286}
287
288fn push_with_sep(out: &mut String, fragment: &str) {
289    if fragment.is_empty() {
290        return;
291    }
292    if !out.is_empty() {
293        out.push('\n');
294    }
295    out.push_str(fragment);
296}