systemprompt_models/wire/canonical/
request.rs1use crate::gateway_hash::conversation_prefix_hash;
10use serde_json::Value;
11use systemprompt_identifiers::GatewayConversationId;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Role {
15 System,
16 User,
17 Assistant,
18 Tool,
19}
20
21impl Role {
22 pub const fn as_str(self) -> &'static str {
23 match self {
24 Self::System => "system",
25 Self::User => "user",
26 Self::Assistant => "assistant",
27 Self::Tool => "tool",
28 }
29 }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ImageDetail {
34 Auto,
35 Low,
36 High,
37}
38
39impl ImageDetail {
40 pub const fn as_str(self) -> &'static str {
41 match self {
42 Self::Auto => "auto",
43 Self::Low => "low",
44 Self::High => "high",
45 }
46 }
47}
48
49#[derive(Debug, Clone)]
50pub enum ImageSource {
51 Base64 {
52 media_type: String,
53 data: String,
54 detail: Option<ImageDetail>,
55 },
56 Url {
57 url: String,
58 detail: Option<ImageDetail>,
59 },
60}
61
62#[derive(Debug, Clone)]
63pub enum CanonicalContent {
64 Text(String),
65 Image(ImageSource),
66 ToolUse {
67 id: String,
68 name: String,
69 input: Value,
70 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>,
89 encrypted_content: Option<String>,
90 },
91}
92
93#[derive(Debug, Clone)]
94pub struct CanonicalMessage {
95 pub role: Role,
96 pub content: Vec<CanonicalContent>,
97}
98
99#[derive(Debug, Clone)]
100pub struct CanonicalTool {
101 pub name: String,
102 pub description: Option<String>,
103 pub input_schema: Value,
104}
105
106#[derive(Debug, Clone)]
107pub enum CanonicalToolChoice {
108 Auto,
109 Any,
110 None,
111 Required,
112 Tool(String),
113}
114
115#[derive(Debug, Clone, Copy, Default)]
116pub struct ThinkingConfig {
117 pub enabled: bool,
118 pub budget_tokens: Option<u32>,
119}
120
121#[derive(Debug, Clone)]
122pub enum ResponseFormat {
123 JsonObject,
124 JsonSchema {
125 name: String,
126 schema: Value,
127 strict: bool,
128 },
129}
130
131#[derive(
132 Debug,
133 Clone,
134 Copy,
135 PartialEq,
136 Eq,
137 PartialOrd,
138 Ord,
139 serde::Serialize,
140 serde::Deserialize,
141 schemars::JsonSchema,
142)]
143#[serde(rename_all = "snake_case")]
144pub enum ReasoningEffort {
145 Low,
146 Medium,
147 High,
148}
149
150impl ReasoningEffort {
151 pub const fn as_str(self) -> &'static str {
152 match self {
153 Self::Low => "low",
154 Self::Medium => "medium",
155 Self::High => "high",
156 }
157 }
158}
159
160#[derive(Debug, Clone, Default)]
161pub struct SearchConfig {
162 pub max_uses: Option<u32>,
163 pub context_size: Option<String>,
164 pub urls: Vec<String>,
165}
166
167#[derive(Debug, Clone)]
168pub struct CanonicalRequest {
169 pub model: String,
170 pub system: Option<String>,
171 pub messages: Vec<CanonicalMessage>,
172 pub max_tokens: u32,
173 pub temperature: Option<f32>,
174 pub top_p: Option<f32>,
175 pub top_k: Option<i32>,
176 pub stop_sequences: Vec<String>,
177 pub tools: Vec<CanonicalTool>,
178 pub tool_choice: Option<CanonicalToolChoice>,
179 pub stream: bool,
180 pub thinking: Option<ThinkingConfig>,
181 pub metadata: Option<Value>,
182 pub response_format: Option<ResponseFormat>,
183 pub reasoning_effort: Option<ReasoningEffort>,
184 pub search: Option<SearchConfig>,
185 pub code_execution: bool,
186 pub presence_penalty: Option<f32>,
187 pub frequency_penalty: Option<f32>,
188}
189
190impl CanonicalRequest {
191 pub fn flatten_text(&self) -> String {
192 let mut out = String::new();
193 if let Some(sys) = &self.system {
194 push_with_sep(&mut out, sys);
195 }
196 for msg in &self.messages {
197 for part in &msg.content {
198 flatten_part(&mut out, part);
199 }
200 }
201 out
202 }
203
204 pub fn derived_gateway_conversation_id(&self) -> Option<GatewayConversationId> {
205 let first = self.messages.first()?;
206 let mut content = String::new();
207 for part in &first.content {
208 flatten_part(&mut content, part);
209 }
210 let hash = conversation_prefix_hash(self.system.as_deref(), first.role.as_str(), &content);
211 Some(GatewayConversationId::from_prefix_hash(hash))
212 }
213
214 pub fn flatten_message_text(&self, role: Role) -> Option<String> {
215 let mut out = String::new();
216 for msg in &self.messages {
217 if msg.role != role {
218 continue;
219 }
220 for part in &msg.content {
221 flatten_part(&mut out, part);
222 }
223 }
224 if out.is_empty() { None } else { Some(out) }
225 }
226
227 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> {
247 let mut units = Vec::with_capacity(self.messages.len() + 1);
248 if let Some(sys) = &self.system {
249 units.push(sys.clone());
250 }
251 for msg in &self.messages {
252 let mut out = String::new();
253 for part in &msg.content {
254 flatten_part(&mut out, part);
255 }
256 if !out.is_empty() {
257 units.push(out);
258 }
259 }
260 units
261 }
262}
263
264fn flatten_part(out: &mut String, part: &CanonicalContent) {
265 match part {
266 CanonicalContent::Text(t) => push_with_sep(out, t),
267 CanonicalContent::Thinking { text, .. } => push_with_sep(out, text),
268 CanonicalContent::ToolUse { name, input, .. } => {
269 push_with_sep(out, &format!("[tool_use:{name} {input}]"));
270 },
271 CanonicalContent::ToolResult { content, .. } => {
272 for inner in content {
273 flatten_part(out, inner);
274 }
275 },
276 CanonicalContent::Image(_) => {},
277 }
278}
279
280fn push_with_sep(out: &mut String, fragment: &str) {
281 if fragment.is_empty() {
282 return;
283 }
284 if !out.is_empty() {
285 out.push('\n');
286 }
287 out.push_str(fragment);
288}