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,
197}
198
199impl CanonicalRequest {
200 pub fn flatten_text(&self) -> String {
204 let mut out = String::new();
205 if let Some(sys) = &self.system {
206 push_with_sep(&mut out, sys);
207 }
208 for msg in &self.messages {
209 for part in &msg.content {
210 flatten_part(&mut out, part);
211 }
212 }
213 for leaf in self.forwarded_surface.leaves() {
214 push_with_sep(&mut out, &leaf.value);
215 }
216 out
217 }
218
219 pub fn derived_gateway_conversation_id(&self) -> Option<GatewayConversationId> {
220 let first = self.messages.first()?;
221 let mut content = String::new();
222 for part in &first.content {
223 flatten_part(&mut content, part);
224 }
225 let hash = conversation_prefix_hash(self.system.as_deref(), first.role.as_str(), &content);
226 Some(GatewayConversationId::from_prefix_hash(hash))
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> {
248 let msg = self.messages.iter().rev().find(|m| m.role == role)?;
249 let mut out = String::new();
250 for part in &msg.content {
251 flatten_part(&mut out, part);
252 }
253 if out.is_empty() { None } else { Some(out) }
254 }
255
256 pub fn message_units(&self) -> Vec<String> {
261 let mut units = Vec::with_capacity(self.messages.len() + self.forwarded_surface.len() + 1);
262 if let Some(sys) = &self.system {
263 units.push(sys.clone());
264 }
265 for msg in &self.messages {
266 let mut out = String::new();
267 for part in &msg.content {
268 flatten_part(&mut out, part);
269 }
270 if !out.is_empty() {
271 units.push(out);
272 }
273 }
274 for leaf in self.forwarded_surface.leaves() {
275 units.push(leaf.value.clone());
276 }
277 units
278 }
279}
280
281pub(super) fn flatten_part(out: &mut String, part: &CanonicalContent) {
282 match part {
283 CanonicalContent::Text(t) => push_with_sep(out, t),
284 CanonicalContent::Thinking { text, .. } => push_with_sep(out, text),
285 CanonicalContent::ToolUse { name, input, .. } => {
286 push_with_sep(out, &format!("[tool_use:{name} {input}]"));
287 },
288 CanonicalContent::ToolResult { content, .. } => {
289 for inner in content {
290 flatten_part(out, inner);
291 }
292 },
293 CanonicalContent::Image(_) => {},
294 }
295}
296
297fn push_with_sep(out: &mut String, fragment: &str) {
298 if fragment.is_empty() {
299 return;
300 }
301 if !out.is_empty() {
302 out.push('\n');
303 }
304 out.push_str(fragment);
305}