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    // Why: Anthropic's system prompt is an array of blocks, each its own cache
31    // breakpoint; flattening to one string would drop the breakpoints.
32    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    // JSON: Free-form request metadata mirrored to `ai_requests.metadata` (JSONB).
44    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    // Why: the system blocks join on a newline, the same view the flat string
82    // gave before the array shape, so conversation ids derived from it hold.
83    #[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    // Why: the caller's own session travels inside `metadata.user_id`; it is
133    // read here, before the identity is stripped for the upstream.
134    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}