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