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
6use crate::gateway_hash::conversation_prefix_hash;
7use serde_json::Value;
8use systemprompt_identifiers::GatewayConversationId;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Role {
12    System,
13    User,
14    Assistant,
15    Tool,
16}
17
18impl Role {
19    pub const fn as_str(self) -> &'static str {
20        match self {
21            Self::System => "system",
22            Self::User => "user",
23            Self::Assistant => "assistant",
24            Self::Tool => "tool",
25        }
26    }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ImageDetail {
31    Auto,
32    Low,
33    High,
34}
35
36impl ImageDetail {
37    pub const fn as_str(self) -> &'static str {
38        match self {
39            Self::Auto => "auto",
40            Self::Low => "low",
41            Self::High => "high",
42        }
43    }
44}
45
46#[derive(Debug, Clone)]
47pub enum ImageSource {
48    Base64 {
49        media_type: String,
50        data: String,
51        detail: Option<ImageDetail>,
52    },
53    Url {
54        url: String,
55        detail: Option<ImageDetail>,
56    },
57}
58
59#[derive(Debug, Clone)]
60pub enum CanonicalContent {
61    Text(String),
62    Image(ImageSource),
63    ToolUse {
64        id: String,
65        name: String,
66        input: Value,
67        // Gemini attaches an opaque `thoughtSignature` to function-call parts that
68        // must be echoed back verbatim on the next turn; this carries it through.
69        signature: Option<String>,
70    },
71    ToolResult {
72        tool_use_id: String,
73        content: Vec<Self>,
74        is_error: bool,
75        structured_content: Option<Value>,
76        meta: Option<Value>,
77    },
78    Thinking {
79        text: String,
80        signature: Option<String>,
81    },
82}
83
84#[derive(Debug, Clone)]
85pub struct CanonicalMessage {
86    pub role: Role,
87    pub content: Vec<CanonicalContent>,
88}
89
90#[derive(Debug, Clone)]
91pub struct CanonicalTool {
92    pub name: String,
93    pub description: Option<String>,
94    pub input_schema: Value,
95}
96
97#[derive(Debug, Clone)]
98pub enum CanonicalToolChoice {
99    Auto,
100    Any,
101    None,
102    Required,
103    Tool(String),
104}
105
106#[derive(Debug, Clone, Copy, Default)]
107pub struct ThinkingConfig {
108    pub enabled: bool,
109    pub budget_tokens: Option<u32>,
110}
111
112#[derive(Debug, Clone)]
113pub enum ResponseFormat {
114    JsonObject,
115    JsonSchema {
116        name: String,
117        schema: Value,
118        strict: bool,
119    },
120}
121
122#[derive(
123    Debug,
124    Clone,
125    Copy,
126    PartialEq,
127    Eq,
128    PartialOrd,
129    Ord,
130    serde::Serialize,
131    serde::Deserialize,
132    schemars::JsonSchema,
133)]
134#[serde(rename_all = "snake_case")]
135pub enum ReasoningEffort {
136    Low,
137    Medium,
138    High,
139}
140
141impl ReasoningEffort {
142    pub const fn as_str(self) -> &'static str {
143        match self {
144            Self::Low => "low",
145            Self::Medium => "medium",
146            Self::High => "high",
147        }
148    }
149}
150
151#[derive(Debug, Clone, Default)]
152pub struct SearchConfig {
153    pub max_uses: Option<u32>,
154    pub context_size: Option<String>,
155    pub urls: Vec<String>,
156}
157
158#[derive(Debug, Clone)]
159pub struct CanonicalRequest {
160    pub model: String,
161    pub system: Option<String>,
162    pub messages: Vec<CanonicalMessage>,
163    pub max_tokens: u32,
164    pub temperature: Option<f32>,
165    pub top_p: Option<f32>,
166    pub top_k: Option<i32>,
167    pub stop_sequences: Vec<String>,
168    pub tools: Vec<CanonicalTool>,
169    pub tool_choice: Option<CanonicalToolChoice>,
170    pub stream: bool,
171    pub thinking: Option<ThinkingConfig>,
172    pub metadata: Option<Value>,
173    pub response_format: Option<ResponseFormat>,
174    pub reasoning_effort: Option<ReasoningEffort>,
175    pub search: Option<SearchConfig>,
176    pub code_execution: bool,
177    pub presence_penalty: Option<f32>,
178    pub frequency_penalty: Option<f32>,
179}
180
181impl CanonicalRequest {
182    pub fn flatten_text(&self) -> String {
183        let mut out = String::new();
184        if let Some(sys) = &self.system {
185            push_with_sep(&mut out, sys);
186        }
187        for msg in &self.messages {
188            for part in &msg.content {
189                flatten_part(&mut out, part);
190            }
191        }
192        out
193    }
194
195    pub fn derived_gateway_conversation_id(&self) -> Option<GatewayConversationId> {
196        let first = self.messages.first()?;
197        let mut content = String::new();
198        for part in &first.content {
199            flatten_part(&mut content, part);
200        }
201        let hash = conversation_prefix_hash(self.system.as_deref(), first.role.as_str(), &content);
202        Some(GatewayConversationId::from_prefix_hash(hash))
203    }
204
205    pub fn flatten_message_text(&self, role: Role) -> Option<String> {
206        let mut out = String::new();
207        for msg in &self.messages {
208            if msg.role != role {
209                continue;
210            }
211            for part in &msg.content {
212                flatten_part(&mut out, part);
213            }
214        }
215        if out.is_empty() { None } else { Some(out) }
216    }
217}
218
219fn flatten_part(out: &mut String, part: &CanonicalContent) {
220    match part {
221        CanonicalContent::Text(t) => push_with_sep(out, t),
222        CanonicalContent::Thinking { text, .. } => push_with_sep(out, text),
223        CanonicalContent::ToolUse { name, input, .. } => {
224            push_with_sep(out, &format!("[tool_use:{name} {input}]"));
225        },
226        CanonicalContent::ToolResult { content, .. } => {
227            for inner in content {
228                flatten_part(out, inner);
229            }
230        },
231        CanonicalContent::Image(_) => {},
232    }
233}
234
235fn push_with_sep(out: &mut String, fragment: &str) {
236    if fragment.is_empty() {
237        return;
238    }
239    if !out.is_empty() {
240        out.push('\n');
241    }
242    out.push_str(fragment);
243}