Skip to main content

systemprompt_models/wire/canonical/
request.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
9use crate::gateway_hash::conversation_prefix_hash;
10use serde_json::Value;
11use systemprompt_identifiers::GatewayConversationId;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Role {
15    System,
16    User,
17    Assistant,
18    Tool,
19}
20
21impl Role {
22    pub const fn as_str(self) -> &'static str {
23        match self {
24            Self::System => "system",
25            Self::User => "user",
26            Self::Assistant => "assistant",
27            Self::Tool => "tool",
28        }
29    }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ImageDetail {
34    Auto,
35    Low,
36    High,
37}
38
39impl ImageDetail {
40    pub const fn as_str(self) -> &'static str {
41        match self {
42            Self::Auto => "auto",
43            Self::Low => "low",
44            Self::High => "high",
45        }
46    }
47}
48
49#[derive(Debug, Clone)]
50pub enum ImageSource {
51    Base64 {
52        media_type: String,
53        data: String,
54        detail: Option<ImageDetail>,
55    },
56    Url {
57        url: String,
58        detail: Option<ImageDetail>,
59    },
60}
61
62#[derive(Debug, Clone)]
63pub enum CanonicalContent {
64    Text(String),
65    Image(ImageSource),
66    ToolUse {
67        id: String,
68        name: String,
69        input: Value,
70        // Why: Gemini attaches an opaque `thoughtSignature` to function-call parts that
71        // must be echoed back verbatim on the next turn; this carries it through.
72        signature: Option<String>,
73    },
74    ToolResult {
75        tool_use_id: String,
76        content: Vec<Self>,
77        is_error: bool,
78        structured_content: Option<Value>,
79        meta: Option<Value>,
80    },
81    Thinking {
82        text: String,
83        signature: Option<String>,
84        // Why: OpenAI Responses reasoning items carry a provider id and, with
85        // `include: ["reasoning.encrypted_content"]`, an opaque blob — both must
86        // be replayed verbatim for stateless reasoning continuity, exactly like
87        // Gemini's thoughtSignature. Anthropic/Gemini thinking has neither.
88        id: Option<String>,
89        encrypted_content: Option<String>,
90    },
91}
92
93#[derive(Debug, Clone)]
94pub struct CanonicalMessage {
95    pub role: Role,
96    pub content: Vec<CanonicalContent>,
97}
98
99#[derive(Debug, Clone)]
100pub struct CanonicalTool {
101    pub name: String,
102    pub description: Option<String>,
103    pub input_schema: Value,
104}
105
106#[derive(Debug, Clone)]
107pub enum CanonicalToolChoice {
108    Auto,
109    Any,
110    None,
111    Required,
112    Tool(String),
113}
114
115#[derive(Debug, Clone, Copy, Default)]
116pub struct ThinkingConfig {
117    pub enabled: bool,
118    pub budget_tokens: Option<u32>,
119}
120
121#[derive(Debug, Clone)]
122pub enum ResponseFormat {
123    JsonObject,
124    JsonSchema {
125        name: String,
126        schema: Value,
127        strict: bool,
128    },
129}
130
131#[derive(
132    Debug,
133    Clone,
134    Copy,
135    PartialEq,
136    Eq,
137    PartialOrd,
138    Ord,
139    serde::Serialize,
140    serde::Deserialize,
141    schemars::JsonSchema,
142)]
143#[serde(rename_all = "snake_case")]
144pub enum ReasoningEffort {
145    Low,
146    Medium,
147    High,
148}
149
150impl ReasoningEffort {
151    pub const fn as_str(self) -> &'static str {
152        match self {
153            Self::Low => "low",
154            Self::Medium => "medium",
155            Self::High => "high",
156        }
157    }
158}
159
160#[derive(Debug, Clone, Default)]
161pub struct SearchConfig {
162    pub max_uses: Option<u32>,
163    pub context_size: Option<String>,
164    pub urls: Vec<String>,
165}
166
167#[derive(Debug, Clone)]
168pub struct CanonicalRequest {
169    pub model: String,
170    pub system: Option<String>,
171    pub messages: Vec<CanonicalMessage>,
172    pub max_tokens: u32,
173    pub temperature: Option<f32>,
174    pub top_p: Option<f32>,
175    pub top_k: Option<i32>,
176    pub stop_sequences: Vec<String>,
177    pub tools: Vec<CanonicalTool>,
178    pub tool_choice: Option<CanonicalToolChoice>,
179    pub stream: bool,
180    pub thinking: Option<ThinkingConfig>,
181    pub metadata: Option<Value>,
182    pub response_format: Option<ResponseFormat>,
183    pub reasoning_effort: Option<ReasoningEffort>,
184    pub search: Option<SearchConfig>,
185    pub code_execution: bool,
186    pub presence_penalty: Option<f32>,
187    pub frequency_penalty: Option<f32>,
188}
189
190impl CanonicalRequest {
191    pub fn flatten_text(&self) -> String {
192        let mut out = String::new();
193        if let Some(sys) = &self.system {
194            push_with_sep(&mut out, sys);
195        }
196        for msg in &self.messages {
197            for part in &msg.content {
198                flatten_part(&mut out, part);
199            }
200        }
201        out
202    }
203
204    pub fn derived_gateway_conversation_id(&self) -> Option<GatewayConversationId> {
205        let first = self.messages.first()?;
206        let mut content = String::new();
207        for part in &first.content {
208            flatten_part(&mut content, part);
209        }
210        let hash = conversation_prefix_hash(self.system.as_deref(), first.role.as_str(), &content);
211        Some(GatewayConversationId::from_prefix_hash(hash))
212    }
213
214    pub fn flatten_message_text(&self, role: Role) -> Option<String> {
215        let mut out = String::new();
216        for msg in &self.messages {
217            if msg.role != role {
218                continue;
219            }
220            for part in &msg.content {
221                flatten_part(&mut out, part);
222            }
223        }
224        if out.is_empty() { None } else { Some(out) }
225    }
226
227    /// Flattens the newest message of `role`, ignoring every earlier one.
228    ///
229    /// Distinct from [`Self::flatten_message_text`], which concatenates every
230    /// message of the role: a safety scanner that judges the latter re-reads
231    /// what the caller already sent on previous turns.
232    pub fn latest_message_text(&self, role: Role) -> Option<String> {
233        let msg = self.messages.iter().rev().find(|m| m.role == role)?;
234        let mut out = String::new();
235        for part in &msg.content {
236            flatten_part(&mut out, part);
237        }
238        if out.is_empty() { None } else { Some(out) }
239    }
240
241    /// Flattens the system prompt and each message into its own string.
242    ///
243    /// Detectors that slide a window over their input need this rather than
244    /// [`Self::flatten_text`], whose concatenation lets two unrelated messages
245    /// splice into a match neither one contains.
246    pub fn message_units(&self) -> Vec<String> {
247        let mut units = Vec::with_capacity(self.messages.len() + 1);
248        if let Some(sys) = &self.system {
249            units.push(sys.clone());
250        }
251        for msg in &self.messages {
252            let mut out = String::new();
253            for part in &msg.content {
254                flatten_part(&mut out, part);
255            }
256            if !out.is_empty() {
257                units.push(out);
258            }
259        }
260        units
261    }
262}
263
264fn flatten_part(out: &mut String, part: &CanonicalContent) {
265    match part {
266        CanonicalContent::Text(t) => push_with_sep(out, t),
267        CanonicalContent::Thinking { text, .. } => push_with_sep(out, text),
268        CanonicalContent::ToolUse { name, input, .. } => {
269            push_with_sep(out, &format!("[tool_use:{name} {input}]"));
270        },
271        CanonicalContent::ToolResult { content, .. } => {
272            for inner in content {
273                flatten_part(out, inner);
274            }
275        },
276        CanonicalContent::Image(_) => {},
277    }
278}
279
280fn push_with_sep(out: &mut String, fragment: &str) {
281    if fragment.is_empty() {
282        return;
283    }
284    if !out.is_empty() {
285        out.push('\n');
286    }
287    out.push_str(fragment);
288}