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