Skip to main content

systemprompt_api/services/gateway/protocol/
canonical.rs

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