systemprompt_models/wire/canonical/
request.rs1use crate::gateway_hash::conversation_prefix_hash;
7use serde_json::Value;
8use systemprompt_identifiers::GatewayConversationId;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Role {
12 System,
13 User,
14 Assistant,
15 Tool,
16}
17
18impl Role {
19 pub const fn as_str(self) -> &'static str {
20 match self {
21 Self::System => "system",
22 Self::User => "user",
23 Self::Assistant => "assistant",
24 Self::Tool => "tool",
25 }
26 }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ImageDetail {
31 Auto,
32 Low,
33 High,
34}
35
36impl ImageDetail {
37 pub const fn as_str(self) -> &'static str {
38 match self {
39 Self::Auto => "auto",
40 Self::Low => "low",
41 Self::High => "high",
42 }
43 }
44}
45
46#[derive(Debug, Clone)]
47pub enum ImageSource {
48 Base64 {
49 media_type: String,
50 data: String,
51 detail: Option<ImageDetail>,
52 },
53 Url {
54 url: String,
55 detail: Option<ImageDetail>,
56 },
57}
58
59#[derive(Debug, Clone)]
60pub enum CanonicalContent {
61 Text(String),
62 Image(ImageSource),
63 ToolUse {
64 id: String,
65 name: String,
66 input: Value,
67 signature: Option<String>,
70 },
71 ToolResult {
72 tool_use_id: String,
73 content: Vec<Self>,
74 is_error: bool,
75 structured_content: Option<Value>,
76 meta: Option<Value>,
77 },
78 Thinking {
79 text: String,
80 signature: Option<String>,
81 },
82}
83
84#[derive(Debug, Clone)]
85pub struct CanonicalMessage {
86 pub role: Role,
87 pub content: Vec<CanonicalContent>,
88}
89
90#[derive(Debug, Clone)]
91pub struct CanonicalTool {
92 pub name: String,
93 pub description: Option<String>,
94 pub input_schema: Value,
95}
96
97#[derive(Debug, Clone)]
98pub enum CanonicalToolChoice {
99 Auto,
100 Any,
101 None,
102 Required,
103 Tool(String),
104}
105
106#[derive(Debug, Clone, Copy, Default)]
107pub struct ThinkingConfig {
108 pub enabled: bool,
109 pub budget_tokens: Option<u32>,
110}
111
112#[derive(Debug, Clone)]
113pub enum ResponseFormat {
114 JsonObject,
115 JsonSchema {
116 name: String,
117 schema: Value,
118 strict: bool,
119 },
120}
121
122#[derive(
123 Debug,
124 Clone,
125 Copy,
126 PartialEq,
127 Eq,
128 PartialOrd,
129 Ord,
130 serde::Serialize,
131 serde::Deserialize,
132 schemars::JsonSchema,
133)]
134#[serde(rename_all = "snake_case")]
135pub enum ReasoningEffort {
136 Low,
137 Medium,
138 High,
139}
140
141impl ReasoningEffort {
142 pub const fn as_str(self) -> &'static str {
143 match self {
144 Self::Low => "low",
145 Self::Medium => "medium",
146 Self::High => "high",
147 }
148 }
149}
150
151#[derive(Debug, Clone, Default)]
152pub struct SearchConfig {
153 pub max_uses: Option<u32>,
154 pub context_size: Option<String>,
155 pub urls: Vec<String>,
156}
157
158#[derive(Debug, Clone)]
159pub struct CanonicalRequest {
160 pub model: String,
161 pub system: Option<String>,
162 pub messages: Vec<CanonicalMessage>,
163 pub max_tokens: u32,
164 pub temperature: Option<f32>,
165 pub top_p: Option<f32>,
166 pub top_k: Option<i32>,
167 pub stop_sequences: Vec<String>,
168 pub tools: Vec<CanonicalTool>,
169 pub tool_choice: Option<CanonicalToolChoice>,
170 pub stream: bool,
171 pub thinking: Option<ThinkingConfig>,
172 pub metadata: Option<Value>,
173 pub response_format: Option<ResponseFormat>,
174 pub reasoning_effort: Option<ReasoningEffort>,
175 pub search: Option<SearchConfig>,
176 pub code_execution: bool,
177 pub presence_penalty: Option<f32>,
178 pub frequency_penalty: Option<f32>,
179}
180
181impl CanonicalRequest {
182 pub fn flatten_text(&self) -> String {
183 let mut out = String::new();
184 if let Some(sys) = &self.system {
185 push_with_sep(&mut out, sys);
186 }
187 for msg in &self.messages {
188 for part in &msg.content {
189 flatten_part(&mut out, part);
190 }
191 }
192 out
193 }
194
195 pub fn derived_gateway_conversation_id(&self) -> Option<GatewayConversationId> {
196 let first = self.messages.first()?;
197 let mut content = String::new();
198 for part in &first.content {
199 flatten_part(&mut content, part);
200 }
201 let hash = conversation_prefix_hash(self.system.as_deref(), first.role.as_str(), &content);
202 Some(GatewayConversationId::from_prefix_hash(hash))
203 }
204
205 pub fn flatten_message_text(&self, role: Role) -> Option<String> {
206 let mut out = String::new();
207 for msg in &self.messages {
208 if msg.role != role {
209 continue;
210 }
211 for part in &msg.content {
212 flatten_part(&mut out, part);
213 }
214 }
215 if out.is_empty() { None } else { Some(out) }
216 }
217}
218
219fn flatten_part(out: &mut String, part: &CanonicalContent) {
220 match part {
221 CanonicalContent::Text(t) => push_with_sep(out, t),
222 CanonicalContent::Thinking { text, .. } => push_with_sep(out, text),
223 CanonicalContent::ToolUse { name, input, .. } => {
224 push_with_sep(out, &format!("[tool_use:{name} {input}]"));
225 },
226 CanonicalContent::ToolResult { content, .. } => {
227 for inner in content {
228 flatten_part(out, inner);
229 }
230 },
231 CanonicalContent::Image(_) => {},
232 }
233}
234
235fn push_with_sep(out: &mut String, fragment: &str) {
236 if fragment.is_empty() {
237 return;
238 }
239 if !out.is_empty() {
240 out.push('\n');
241 }
242 out.push_str(fragment);
243}