systemprompt_models/wire/canonical/
request.rs1use 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 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 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 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}