Skip to main content

systemprompt_models/wire/canonical/request/
mod.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//!
6//! Copyright (c) systemprompt.io — Business Source License 1.1.
7//! See <https://systemprompt.io> for licensing details.
8
9mod content;
10mod options;
11
12pub use content::{CanonicalContent, CanonicalMessage, ImageDetail, ImageSource, Role};
13pub use options::{
14    CanonicalTool, CanonicalToolChoice, ReasoningEffort, ResponseFormat, SearchConfig,
15    ThinkingConfig,
16};
17
18use crate::gateway_hash::conversation_prefix_hash;
19use crate::wire::inspect::ForwardedSurface;
20use serde_json::Value;
21use systemprompt_identifiers::error::IdValidationError;
22use systemprompt_identifiers::{ClientSessionId, GatewayConversationId, ModelId};
23
24#[derive(Debug, Clone)]
25pub struct CanonicalRequest {
26    pub model: ModelId,
27    pub system: Option<String>,
28    pub messages: Vec<CanonicalMessage>,
29    pub max_tokens: u32,
30    pub temperature: Option<f32>,
31    pub top_p: Option<f32>,
32    pub top_k: Option<i32>,
33    pub stop_sequences: Vec<String>,
34    pub tools: Vec<CanonicalTool>,
35    pub tool_choice: Option<CanonicalToolChoice>,
36    pub stream: bool,
37    pub thinking: Option<ThinkingConfig>,
38    // JSON: Free-form request metadata mirrored to `ai_requests.metadata` (JSONB).
39    pub metadata: Option<Value>,
40    pub response_format: Option<ResponseFormat>,
41    pub reasoning_effort: Option<ReasoningEffort>,
42    pub search: Option<SearchConfig>,
43    pub code_execution: bool,
44    pub presence_penalty: Option<f32>,
45    pub frequency_penalty: Option<f32>,
46    pub forwarded_surface: ForwardedSurface,
47}
48
49impl CanonicalRequest {
50    #[must_use]
51    pub fn new(model: ModelId, messages: Vec<CanonicalMessage>, max_tokens: u32) -> Self {
52        Self {
53            model,
54            system: None,
55            messages,
56            max_tokens,
57            temperature: None,
58            top_p: None,
59            top_k: None,
60            stop_sequences: Vec::new(),
61            tools: Vec::new(),
62            tool_choice: None,
63            stream: false,
64            thinking: None,
65            metadata: None,
66            response_format: None,
67            reasoning_effort: None,
68            search: None,
69            code_execution: false,
70            presence_penalty: None,
71            frequency_penalty: None,
72            forwarded_surface: ForwardedSurface::default(),
73        }
74    }
75
76    pub fn flatten_parts(&self) -> Vec<(String, String)> {
77        let mut parts = Vec::with_capacity(self.messages.len() + self.forwarded_surface.len() + 1);
78        if let Some(sys) = &self.system
79            && !sys.is_empty()
80        {
81            parts.push(("system".to_owned(), sys.clone()));
82        }
83        for (index, msg) in self.messages.iter().enumerate() {
84            let mut out = String::new();
85            for part in &msg.content {
86                flatten_part(&mut out, part);
87            }
88            if !out.is_empty() {
89                parts.push((format!("messages[{index}].{}", msg.role.as_str()), out));
90            }
91        }
92        for leaf in self.forwarded_surface.leaves() {
93            parts.push((format!("forwarded.{}", leaf.path), leaf.value.clone()));
94        }
95        parts
96    }
97
98    pub fn derived_gateway_conversation_id(&self) -> Option<GatewayConversationId> {
99        let first = self.messages.first()?;
100        let mut content = String::new();
101        for part in &first.content {
102            flatten_part(&mut content, part);
103        }
104        let hash = conversation_prefix_hash(self.system.as_deref(), first.role.as_str(), &content);
105        Some(GatewayConversationId::from_prefix_hash(hash))
106    }
107
108    // Why: the caller's own session travels inside `metadata.user_id`; it is
109    // read here, before the identity is stripped for the upstream.
110    pub fn client_session_id(&self) -> Result<Option<ClientSessionId>, IdValidationError> {
111        let Some(value) = self.metadata.as_ref().and_then(|m| m.get("user_id")) else {
112            return Ok(None);
113        };
114        let value = value.as_str().ok_or_else(|| IdValidationError::Invalid {
115            id_type: "ClientSessionId",
116            message: "metadata.user_id must be a string".to_owned(),
117        })?;
118        ClientSessionId::from_metadata_user_id(value)
119    }
120
121    pub fn flatten_message_text(&self, role: Role) -> Option<String> {
122        let mut out = String::new();
123        for msg in &self.messages {
124            if msg.role != role {
125                continue;
126            }
127            for part in &msg.content {
128                flatten_part(&mut out, part);
129            }
130        }
131        if out.is_empty() { None } else { Some(out) }
132    }
133
134    pub fn latest_message_text(&self, role: Role) -> Option<String> {
135        let msg = self.messages.iter().rev().find(|m| m.role == role)?;
136        let mut out = String::new();
137        for part in &msg.content {
138            flatten_part(&mut out, part);
139        }
140        if out.is_empty() { None } else { Some(out) }
141    }
142
143    pub fn message_units(&self) -> Vec<String> {
144        let mut units = Vec::with_capacity(self.messages.len() + self.forwarded_surface.len() + 1);
145        if let Some(sys) = &self.system {
146            units.push(sys.clone());
147        }
148        for msg in &self.messages {
149            let mut out = String::new();
150            for part in &msg.content {
151                flatten_part(&mut out, part);
152            }
153            if !out.is_empty() {
154                units.push(out);
155            }
156        }
157        for leaf in self.forwarded_surface.leaves() {
158            units.push(leaf.value.clone());
159        }
160        units
161    }
162}
163
164pub(super) fn flatten_part(out: &mut String, part: &CanonicalContent) {
165    match part {
166        CanonicalContent::Text(t) => push_with_sep(out, t),
167        CanonicalContent::Thinking { text, .. } => push_with_sep(out, text),
168        CanonicalContent::ToolUse { name, input, .. } => {
169            push_with_sep(out, &format!("[tool_use:{name} {input}]"));
170        },
171        CanonicalContent::ToolResult { content, .. } => {
172            for inner in content {
173                flatten_part(out, inner);
174            }
175        },
176        CanonicalContent::Image(_) => {},
177    }
178}
179
180fn push_with_sep(out: &mut String, fragment: &str) {
181    if fragment.is_empty() {
182        return;
183    }
184    if !out.is_empty() {
185        out.push('\n');
186    }
187    out.push_str(fragment);
188}