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