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