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