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 },
85}
86
87#[derive(Debug, Clone)]
88pub struct CanonicalMessage {
89 pub role: Role,
90 pub content: Vec<CanonicalContent>,
91}
92
93#[derive(Debug, Clone)]
94pub struct CanonicalTool {
95 pub name: String,
96 pub description: Option<String>,
97 pub input_schema: Value,
98}
99
100#[derive(Debug, Clone)]
101pub enum CanonicalToolChoice {
102 Auto,
103 Any,
104 None,
105 Required,
106 Tool(String),
107}
108
109#[derive(Debug, Clone, Copy, Default)]
110pub struct ThinkingConfig {
111 pub enabled: bool,
112 pub budget_tokens: Option<u32>,
113}
114
115#[derive(Debug, Clone)]
116pub enum ResponseFormat {
117 JsonObject,
118 JsonSchema {
119 name: String,
120 schema: Value,
121 strict: bool,
122 },
123}
124
125#[derive(
126 Debug,
127 Clone,
128 Copy,
129 PartialEq,
130 Eq,
131 PartialOrd,
132 Ord,
133 serde::Serialize,
134 serde::Deserialize,
135 schemars::JsonSchema,
136)]
137#[serde(rename_all = "snake_case")]
138pub enum ReasoningEffort {
139 Low,
140 Medium,
141 High,
142}
143
144impl ReasoningEffort {
145 pub const fn as_str(self) -> &'static str {
146 match self {
147 Self::Low => "low",
148 Self::Medium => "medium",
149 Self::High => "high",
150 }
151 }
152}
153
154#[derive(Debug, Clone, Default)]
155pub struct SearchConfig {
156 pub max_uses: Option<u32>,
157 pub context_size: Option<String>,
158 pub urls: Vec<String>,
159}
160
161#[derive(Debug, Clone)]
162pub struct CanonicalRequest {
163 pub model: String,
164 pub system: Option<String>,
165 pub messages: Vec<CanonicalMessage>,
166 pub max_tokens: u32,
167 pub temperature: Option<f32>,
168 pub top_p: Option<f32>,
169 pub top_k: Option<i32>,
170 pub stop_sequences: Vec<String>,
171 pub tools: Vec<CanonicalTool>,
172 pub tool_choice: Option<CanonicalToolChoice>,
173 pub stream: bool,
174 pub thinking: Option<ThinkingConfig>,
175 pub metadata: Option<Value>,
176 pub response_format: Option<ResponseFormat>,
177 pub reasoning_effort: Option<ReasoningEffort>,
178 pub search: Option<SearchConfig>,
179 pub code_execution: bool,
180 pub presence_penalty: Option<f32>,
181 pub frequency_penalty: Option<f32>,
182}
183
184impl CanonicalRequest {
185 pub fn flatten_text(&self) -> String {
186 let mut out = String::new();
187 if let Some(sys) = &self.system {
188 push_with_sep(&mut out, sys);
189 }
190 for msg in &self.messages {
191 for part in &msg.content {
192 flatten_part(&mut out, part);
193 }
194 }
195 out
196 }
197
198 pub fn derived_gateway_conversation_id(&self) -> Option<GatewayConversationId> {
199 let first = self.messages.first()?;
200 let mut content = String::new();
201 for part in &first.content {
202 flatten_part(&mut content, part);
203 }
204 let hash = conversation_prefix_hash(self.system.as_deref(), first.role.as_str(), &content);
205 Some(GatewayConversationId::from_prefix_hash(hash))
206 }
207
208 pub fn flatten_message_text(&self, role: Role) -> Option<String> {
209 let mut out = String::new();
210 for msg in &self.messages {
211 if msg.role != role {
212 continue;
213 }
214 for part in &msg.content {
215 flatten_part(&mut out, part);
216 }
217 }
218 if out.is_empty() { None } else { Some(out) }
219 }
220}
221
222fn flatten_part(out: &mut String, part: &CanonicalContent) {
223 match part {
224 CanonicalContent::Text(t) => push_with_sep(out, t),
225 CanonicalContent::Thinking { text, .. } => push_with_sep(out, text),
226 CanonicalContent::ToolUse { name, input, .. } => {
227 push_with_sep(out, &format!("[tool_use:{name} {input}]"));
228 },
229 CanonicalContent::ToolResult { content, .. } => {
230 for inner in content {
231 flatten_part(out, inner);
232 }
233 },
234 CanonicalContent::Image(_) => {},
235 }
236}
237
238fn push_with_sep(out: &mut String, fragment: &str) {
239 if fragment.is_empty() {
240 return;
241 }
242 if !out.is_empty() {
243 out.push('\n');
244 }
245 out.push_str(fragment);
246}