Skip to main content

systemprompt_api/services/gateway/protocol/
canonical.rs

1//! The provider-neutral request model the gateway translates to and from.
2//!
3//! Inbound adapters parse a wire request into a [`CanonicalRequest`] of
4//! [`CanonicalMessage`]s carrying [`CanonicalContent`] parts; outbound adapters
5//! render it back out. The flattening helpers derive plain-text views and a
6//! stable [`GatewayConversationId`] from the leading message.
7
8use serde_json::Value;
9use systemprompt_identifiers::GatewayConversationId;
10use systemprompt_models::gateway_hash::conversation_prefix_hash;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Role {
14    System,
15    User,
16    Assistant,
17    Tool,
18}
19
20impl Role {
21    pub const fn as_str(self) -> &'static str {
22        match self {
23            Self::System => "system",
24            Self::User => "user",
25            Self::Assistant => "assistant",
26            Self::Tool => "tool",
27        }
28    }
29}
30
31#[derive(Debug, Clone)]
32pub enum ImageSource {
33    Base64 { media_type: String, data: String },
34    Url(String),
35}
36
37#[derive(Debug, Clone)]
38pub enum CanonicalContent {
39    Text(String),
40    Image(ImageSource),
41    ToolUse {
42        id: String,
43        name: String,
44        input: Value,
45    },
46    ToolResult {
47        tool_use_id: String,
48        content: Vec<Self>,
49        is_error: bool,
50    },
51    Thinking {
52        text: String,
53        signature: Option<String>,
54    },
55}
56
57#[derive(Debug, Clone)]
58pub struct CanonicalMessage {
59    pub role: Role,
60    pub content: Vec<CanonicalContent>,
61}
62
63#[derive(Debug, Clone)]
64pub struct CanonicalTool {
65    pub name: String,
66    pub description: Option<String>,
67    pub input_schema: Value,
68}
69
70#[derive(Debug, Clone)]
71pub enum CanonicalToolChoice {
72    Auto,
73    Any,
74    None,
75    Required,
76    Tool(String),
77}
78
79#[derive(Debug, Clone, Copy, Default)]
80pub struct ThinkingConfig {
81    pub enabled: bool,
82    pub budget_tokens: Option<u32>,
83}
84
85#[derive(Debug, Clone)]
86pub struct CanonicalRequest {
87    pub model: String,
88    pub system: Option<String>,
89    pub messages: Vec<CanonicalMessage>,
90    pub max_tokens: u32,
91    pub temperature: Option<f32>,
92    pub top_p: Option<f32>,
93    pub top_k: Option<i32>,
94    pub stop_sequences: Vec<String>,
95    pub tools: Vec<CanonicalTool>,
96    pub tool_choice: Option<CanonicalToolChoice>,
97    pub stream: bool,
98    pub thinking: Option<ThinkingConfig>,
99    pub metadata: Option<Value>,
100}
101
102impl CanonicalRequest {
103    pub fn flatten_text(&self) -> String {
104        let mut out = String::new();
105        if let Some(sys) = &self.system {
106            push_with_sep(&mut out, sys);
107        }
108        for msg in &self.messages {
109            for part in &msg.content {
110                flatten_part(&mut out, part);
111            }
112        }
113        out
114    }
115
116    pub fn derived_gateway_conversation_id(&self) -> Option<GatewayConversationId> {
117        let first = self.messages.first()?;
118        let mut content = String::new();
119        for part in &first.content {
120            flatten_part(&mut content, part);
121        }
122        let hash = conversation_prefix_hash(self.system.as_deref(), first.role.as_str(), &content);
123        Some(GatewayConversationId::from_prefix_hash(hash))
124    }
125
126    pub fn flatten_message_text(&self, role: Role) -> Option<String> {
127        let mut out = String::new();
128        for msg in &self.messages {
129            if msg.role != role {
130                continue;
131            }
132            for part in &msg.content {
133                flatten_part(&mut out, part);
134            }
135        }
136        if out.is_empty() { None } else { Some(out) }
137    }
138}
139
140fn flatten_part(out: &mut String, part: &CanonicalContent) {
141    match part {
142        CanonicalContent::Text(t) => push_with_sep(out, t),
143        CanonicalContent::Thinking { text, .. } => push_with_sep(out, text),
144        CanonicalContent::ToolUse { name, input, .. } => {
145            push_with_sep(out, &format!("[tool_use:{name} {input}]"));
146        },
147        CanonicalContent::ToolResult { content, .. } => {
148            for inner in content {
149                flatten_part(out, inner);
150            }
151        },
152        CanonicalContent::Image(_) => {},
153    }
154}
155
156fn push_with_sep(out: &mut String, fragment: &str) {
157    if fragment.is_empty() {
158        return;
159    }
160    if !out.is_empty() {
161        out.push('\n');
162    }
163    out.push_str(fragment);
164}