systemprompt_models/wire/canonical/
request.rs1use 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 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>,
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 pub forwarded_surface: ForwardedSurface,
190}
191
192impl CanonicalRequest {
193 pub fn flatten_parts(&self) -> Vec<(String, String)> {
194 let mut parts = Vec::with_capacity(self.messages.len() + self.forwarded_surface.len() + 1);
195 if let Some(sys) = &self.system
196 && !sys.is_empty()
197 {
198 parts.push(("system".to_owned(), sys.clone()));
199 }
200 for (index, msg) in self.messages.iter().enumerate() {
201 let mut out = String::new();
202 for part in &msg.content {
203 flatten_part(&mut out, part);
204 }
205 if !out.is_empty() {
206 parts.push((format!("messages[{index}].{}", msg.role.as_str()), out));
207 }
208 }
209 for leaf in self.forwarded_surface.leaves() {
210 parts.push((format!("forwarded.{}", leaf.path), leaf.value.clone()));
211 }
212 parts
213 }
214
215 pub fn derived_gateway_conversation_id(&self) -> Option<GatewayConversationId> {
216 let first = self.messages.first()?;
217 let mut content = String::new();
218 for part in &first.content {
219 flatten_part(&mut content, part);
220 }
221 let hash = conversation_prefix_hash(self.system.as_deref(), first.role.as_str(), &content);
222 Some(GatewayConversationId::from_prefix_hash(hash))
223 }
224
225 pub fn flatten_message_text(&self, role: Role) -> Option<String> {
226 let mut out = String::new();
227 for msg in &self.messages {
228 if msg.role != role {
229 continue;
230 }
231 for part in &msg.content {
232 flatten_part(&mut out, part);
233 }
234 }
235 if out.is_empty() { None } else { Some(out) }
236 }
237
238 pub fn latest_message_text(&self, role: Role) -> Option<String> {
239 let msg = self.messages.iter().rev().find(|m| m.role == role)?;
240 let mut out = String::new();
241 for part in &msg.content {
242 flatten_part(&mut out, part);
243 }
244 if out.is_empty() { None } else { Some(out) }
245 }
246
247 pub fn message_units(&self) -> Vec<String> {
248 let mut units = Vec::with_capacity(self.messages.len() + self.forwarded_surface.len() + 1);
249 if let Some(sys) = &self.system {
250 units.push(sys.clone());
251 }
252 for msg in &self.messages {
253 let mut out = String::new();
254 for part in &msg.content {
255 flatten_part(&mut out, part);
256 }
257 if !out.is_empty() {
258 units.push(out);
259 }
260 }
261 for leaf in self.forwarded_surface.leaves() {
262 units.push(leaf.value.clone());
263 }
264 units
265 }
266}
267
268pub(super) fn flatten_part(out: &mut String, part: &CanonicalContent) {
269 match part {
270 CanonicalContent::Text(t) => push_with_sep(out, t),
271 CanonicalContent::Thinking { text, .. } => push_with_sep(out, text),
272 CanonicalContent::ToolUse { name, input, .. } => {
273 push_with_sep(out, &format!("[tool_use:{name} {input}]"));
274 },
275 CanonicalContent::ToolResult { content, .. } => {
276 for inner in content {
277 flatten_part(out, inner);
278 }
279 },
280 CanonicalContent::Image(_) => {},
281 }
282}
283
284fn push_with_sep(out: &mut String, fragment: &str) {
285 if fragment.is_empty() {
286 return;
287 }
288 if !out.is_empty() {
289 out.push('\n');
290 }
291 out.push_str(fragment);
292}