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        // 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    },
85}
86
87#[derive(Debug, Clone)]
88pub struct CanonicalMessage {
89    pub role: Role,
90    pub content: Vec<CanonicalContent>,
91}
92
93#[derive(Debug, Clone)]
94pub struct CanonicalTool {
95    pub name: String,
96    pub description: Option<String>,
97    pub input_schema: Value,
98}
99
100#[derive(Debug, Clone)]
101pub enum CanonicalToolChoice {
102    Auto,
103    Any,
104    None,
105    Required,
106    Tool(String),
107}
108
109#[derive(Debug, Clone, Copy, Default)]
110pub struct ThinkingConfig {
111    pub enabled: bool,
112    pub budget_tokens: Option<u32>,
113}
114
115#[derive(Debug, Clone)]
116pub enum ResponseFormat {
117    JsonObject,
118    JsonSchema {
119        name: String,
120        schema: Value,
121        strict: bool,
122    },
123}
124
125#[derive(
126    Debug,
127    Clone,
128    Copy,
129    PartialEq,
130    Eq,
131    PartialOrd,
132    Ord,
133    serde::Serialize,
134    serde::Deserialize,
135    schemars::JsonSchema,
136)]
137#[serde(rename_all = "snake_case")]
138pub enum ReasoningEffort {
139    Low,
140    Medium,
141    High,
142}
143
144impl ReasoningEffort {
145    pub const fn as_str(self) -> &'static str {
146        match self {
147            Self::Low => "low",
148            Self::Medium => "medium",
149            Self::High => "high",
150        }
151    }
152}
153
154#[derive(Debug, Clone, Default)]
155pub struct SearchConfig {
156    pub max_uses: Option<u32>,
157    pub context_size: Option<String>,
158    pub urls: Vec<String>,
159}
160
161#[derive(Debug, Clone)]
162pub struct CanonicalRequest {
163    pub model: String,
164    pub system: Option<String>,
165    pub messages: Vec<CanonicalMessage>,
166    pub max_tokens: u32,
167    pub temperature: Option<f32>,
168    pub top_p: Option<f32>,
169    pub top_k: Option<i32>,
170    pub stop_sequences: Vec<String>,
171    pub tools: Vec<CanonicalTool>,
172    pub tool_choice: Option<CanonicalToolChoice>,
173    pub stream: bool,
174    pub thinking: Option<ThinkingConfig>,
175    pub metadata: Option<Value>,
176    pub response_format: Option<ResponseFormat>,
177    pub reasoning_effort: Option<ReasoningEffort>,
178    pub search: Option<SearchConfig>,
179    pub code_execution: bool,
180    pub presence_penalty: Option<f32>,
181    pub frequency_penalty: Option<f32>,
182}
183
184impl CanonicalRequest {
185    pub fn flatten_text(&self) -> String {
186        let mut out = String::new();
187        if let Some(sys) = &self.system {
188            push_with_sep(&mut out, sys);
189        }
190        for msg in &self.messages {
191            for part in &msg.content {
192                flatten_part(&mut out, part);
193            }
194        }
195        out
196    }
197
198    pub fn derived_gateway_conversation_id(&self) -> Option<GatewayConversationId> {
199        let first = self.messages.first()?;
200        let mut content = String::new();
201        for part in &first.content {
202            flatten_part(&mut content, part);
203        }
204        let hash = conversation_prefix_hash(self.system.as_deref(), first.role.as_str(), &content);
205        Some(GatewayConversationId::from_prefix_hash(hash))
206    }
207
208    pub fn flatten_message_text(&self, role: Role) -> Option<String> {
209        let mut out = String::new();
210        for msg in &self.messages {
211            if msg.role != role {
212                continue;
213            }
214            for part in &msg.content {
215                flatten_part(&mut out, part);
216            }
217        }
218        if out.is_empty() { None } else { Some(out) }
219    }
220}
221
222fn flatten_part(out: &mut String, part: &CanonicalContent) {
223    match part {
224        CanonicalContent::Text(t) => push_with_sep(out, t),
225        CanonicalContent::Thinking { text, .. } => push_with_sep(out, text),
226        CanonicalContent::ToolUse { name, input, .. } => {
227            push_with_sep(out, &format!("[tool_use:{name} {input}]"));
228        },
229        CanonicalContent::ToolResult { content, .. } => {
230            for inner in content {
231                flatten_part(out, inner);
232            }
233        },
234        CanonicalContent::Image(_) => {},
235    }
236}
237
238fn push_with_sep(out: &mut String, fragment: &str) {
239    if fragment.is_empty() {
240        return;
241    }
242    if !out.is_empty() {
243        out.push('\n');
244    }
245    out.push_str(fragment);
246}