Skip to main content

systemprompt_models/wire/canonical/request/
mod.rs

1//! The provider-neutral request model the gateway translates to and from.
2//!
3//! The flattening helpers derive plain-text views and a stable
4//! [`GatewayConversationId`] from the leading message.
5//!
6//! Copyright (c) systemprompt.io — Business Source License 1.1.
7//! See <https://systemprompt.io> for licensing details.
8
9mod 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    // Why: Anthropic's system prompt is an array of blocks, each its own cache
32    // breakpoint; flattening to one string would drop the breakpoints.
33    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    // JSON: Free-form request metadata mirrored to `ai_requests.metadata` (JSONB).
45    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    // Why: the system blocks join on a newline, the same view the flat string
84    // gave before the array shape, so conversation ids derived from it hold.
85    #[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    // Why: the caller's own session travels inside `metadata.user_id`; it is
151    // read here, before the identity is stripped for the upstream.
152    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}