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(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum ReasoningEffort {
124    Low,
125    Medium,
126    High,
127}
128
129impl ReasoningEffort {
130    pub const fn as_str(self) -> &'static str {
131        match self {
132            Self::Low => "low",
133            Self::Medium => "medium",
134            Self::High => "high",
135        }
136    }
137}
138
139#[derive(Debug, Clone, Default)]
140pub struct SearchConfig {
141    pub max_uses: Option<u32>,
142    pub context_size: Option<String>,
143    pub urls: Vec<String>,
144}
145
146#[derive(Debug, Clone)]
147pub struct CanonicalRequest {
148    pub model: String,
149    pub system: Option<String>,
150    pub messages: Vec<CanonicalMessage>,
151    pub max_tokens: u32,
152    pub temperature: Option<f32>,
153    pub top_p: Option<f32>,
154    pub top_k: Option<i32>,
155    pub stop_sequences: Vec<String>,
156    pub tools: Vec<CanonicalTool>,
157    pub tool_choice: Option<CanonicalToolChoice>,
158    pub stream: bool,
159    pub thinking: Option<ThinkingConfig>,
160    pub metadata: Option<Value>,
161    pub response_format: Option<ResponseFormat>,
162    pub reasoning_effort: Option<ReasoningEffort>,
163    pub search: Option<SearchConfig>,
164    pub code_execution: bool,
165    pub presence_penalty: Option<f32>,
166    pub frequency_penalty: Option<f32>,
167}
168
169impl CanonicalRequest {
170    pub fn flatten_text(&self) -> String {
171        let mut out = String::new();
172        if let Some(sys) = &self.system {
173            push_with_sep(&mut out, sys);
174        }
175        for msg in &self.messages {
176            for part in &msg.content {
177                flatten_part(&mut out, part);
178            }
179        }
180        out
181    }
182
183    pub fn derived_gateway_conversation_id(&self) -> Option<GatewayConversationId> {
184        let first = self.messages.first()?;
185        let mut content = String::new();
186        for part in &first.content {
187            flatten_part(&mut content, part);
188        }
189        let hash = conversation_prefix_hash(self.system.as_deref(), first.role.as_str(), &content);
190        Some(GatewayConversationId::from_prefix_hash(hash))
191    }
192
193    pub fn flatten_message_text(&self, role: Role) -> Option<String> {
194        let mut out = String::new();
195        for msg in &self.messages {
196            if msg.role != role {
197                continue;
198            }
199            for part in &msg.content {
200                flatten_part(&mut out, part);
201            }
202        }
203        if out.is_empty() { None } else { Some(out) }
204    }
205}
206
207fn flatten_part(out: &mut String, part: &CanonicalContent) {
208    match part {
209        CanonicalContent::Text(t) => push_with_sep(out, t),
210        CanonicalContent::Thinking { text, .. } => push_with_sep(out, text),
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}