systemprompt_models/wire/canonical/request/
mod.rs1mod content;
10mod options;
11
12pub use content::{
13 CacheControl, CacheTtl, CanonicalContent, CanonicalMessage, ImageDetail, ImageSource, Role,
14 SystemBlock,
15};
16pub use options::{
17 CanonicalTool, CanonicalToolChoice, ReasoningEffort, ResponseFormat, SearchConfig,
18 ThinkingConfig,
19};
20
21use crate::gateway_hash::conversation_prefix_hash;
22use crate::wire::inspect::ForwardedSurface;
23use serde_json::Value;
24use systemprompt_identifiers::error::IdValidationError;
25use systemprompt_identifiers::{ClientSessionId, GatewayConversationId, ModelId};
26
27#[derive(Debug, Clone)]
28pub struct CanonicalRequest {
29 pub model: ModelId,
30 pub cache_control: Option<CacheControl>,
31 pub system: Vec<SystemBlock>,
34 pub messages: Vec<CanonicalMessage>,
35 pub max_tokens: u32,
36 pub temperature: Option<f32>,
37 pub top_p: Option<f32>,
38 pub top_k: Option<i32>,
39 pub stop_sequences: Vec<String>,
40 pub tools: Vec<CanonicalTool>,
41 pub tool_choice: Option<CanonicalToolChoice>,
42 pub stream: bool,
43 pub thinking: Option<ThinkingConfig>,
44 pub metadata: Option<Value>,
46 pub response_format: Option<ResponseFormat>,
47 pub reasoning_effort: Option<ReasoningEffort>,
48 pub search: Option<SearchConfig>,
49 pub code_execution: bool,
50 pub presence_penalty: Option<f32>,
51 pub frequency_penalty: Option<f32>,
52 pub forwarded_surface: ForwardedSurface,
53}
54
55impl CanonicalRequest {
56 #[must_use]
57 pub fn new(model: ModelId, messages: Vec<CanonicalMessage>, max_tokens: u32) -> Self {
58 Self {
59 model,
60 cache_control: None,
61 system: Vec::new(),
62 messages,
63 max_tokens,
64 temperature: None,
65 top_p: None,
66 top_k: None,
67 stop_sequences: Vec::new(),
68 tools: Vec::new(),
69 tool_choice: None,
70 stream: false,
71 thinking: None,
72 metadata: None,
73 response_format: None,
74 reasoning_effort: None,
75 search: None,
76 code_execution: false,
77 presence_penalty: None,
78 frequency_penalty: None,
79 forwarded_surface: ForwardedSurface::default(),
80 }
81 }
82
83 #[must_use]
86 pub fn system_text(&self) -> Option<String> {
87 if self.system.is_empty() {
88 return None;
89 }
90 let joined = self
91 .system
92 .iter()
93 .map(|block| block.text.as_str())
94 .collect::<Vec<_>>()
95 .join("\n");
96 (!joined.is_empty()).then_some(joined)
97 }
98
99 pub fn set_system_text(&mut self, text: Option<String>) {
100 self.system = text.map(SystemBlock::text).into_iter().collect();
101 }
102
103 #[must_use]
104 pub fn has_cache_control(&self) -> bool {
105 self.cache_control.is_some()
106 || self
107 .system
108 .iter()
109 .any(|block| block.cache_control.is_some())
110 || self.tools.iter().any(|tool| tool.cache_control.is_some())
111 || self.messages.iter().any(|message| {
112 message
113 .content
114 .iter()
115 .any(|content| content.cache_control().is_some())
116 })
117 }
118
119 pub fn flatten_parts(&self) -> Vec<(String, String)> {
120 let mut parts = Vec::with_capacity(self.messages.len() + self.forwarded_surface.len() + 1);
121 if let Some(sys) = self.system_text() {
122 parts.push(("system".to_owned(), sys));
123 }
124 for (index, msg) in self.messages.iter().enumerate() {
125 let mut out = String::new();
126 for part in &msg.content {
127 flatten_part(&mut out, part);
128 }
129 if !out.is_empty() {
130 parts.push((format!("messages[{index}].{}", msg.role.as_str()), out));
131 }
132 }
133 for leaf in self.forwarded_surface.leaves() {
134 parts.push((format!("forwarded.{}", leaf.path), leaf.value.clone()));
135 }
136 parts
137 }
138
139 pub fn derived_gateway_conversation_id(&self) -> Option<GatewayConversationId> {
140 let first = self.messages.first()?;
141 let mut content = String::new();
142 for part in &first.content {
143 flatten_part(&mut content, part);
144 }
145 let system = self.system_text();
146 let hash = conversation_prefix_hash(system.as_deref(), first.role.as_str(), &content);
147 Some(GatewayConversationId::from_prefix_hash(hash))
148 }
149
150 pub fn client_session_id(&self) -> Result<Option<ClientSessionId>, IdValidationError> {
153 let Some(value) = self.metadata.as_ref().and_then(|m| m.get("user_id")) else {
154 return Ok(None);
155 };
156 let value = value.as_str().ok_or_else(|| IdValidationError::Invalid {
157 id_type: "ClientSessionId",
158 message: "metadata.user_id must be a string".to_owned(),
159 })?;
160 ClientSessionId::from_metadata_user_id(value)
161 }
162
163 pub fn flatten_message_text(&self, role: Role) -> Option<String> {
164 let mut out = String::new();
165 for msg in &self.messages {
166 if msg.role != role {
167 continue;
168 }
169 for part in &msg.content {
170 flatten_part(&mut out, part);
171 }
172 }
173 if out.is_empty() { None } else { Some(out) }
174 }
175
176 pub fn latest_message_text(&self, role: Role) -> Option<String> {
177 let msg = self.messages.iter().rev().find(|m| m.role == role)?;
178 let mut out = String::new();
179 for part in &msg.content {
180 flatten_part(&mut out, part);
181 }
182 if out.is_empty() { None } else { Some(out) }
183 }
184
185 pub fn message_units(&self) -> Vec<String> {
186 let mut units = Vec::with_capacity(self.messages.len() + self.forwarded_surface.len() + 1);
187 if let Some(sys) = self.system_text() {
188 units.push(sys);
189 }
190 for msg in &self.messages {
191 let mut out = String::new();
192 for part in &msg.content {
193 flatten_part(&mut out, part);
194 }
195 if !out.is_empty() {
196 units.push(out);
197 }
198 }
199 for leaf in self.forwarded_surface.leaves() {
200 units.push(leaf.value.clone());
201 }
202 units
203 }
204}
205
206pub(super) fn flatten_part(out: &mut String, part: &CanonicalContent) {
207 match part {
208 CanonicalContent::Text { text, .. } | CanonicalContent::Thinking { text, .. } => {
209 push_with_sep(out, text);
210 },
211 CanonicalContent::ToolUse { name, input, .. } => {
212 push_with_sep(out, &format!("[tool_use:{name} {input}]"));
213 },
214 CanonicalContent::ToolResult { content, .. } => {
215 for inner in content {
216 flatten_part(out, inner);
217 }
218 },
219 CanonicalContent::Image { .. } => {},
220 }
221}
222
223fn push_with_sep(out: &mut String, fragment: &str) {
224 if fragment.is_empty() {
225 return;
226 }
227 if !out.is_empty() {
228 out.push('\n');
229 }
230 out.push_str(fragment);
231}