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