Skip to main content

systemprompt_wire/canonical/request/
content.rs

1//! Message roles, content parts and cache breakpoints of a canonical request.
2//!
3//! `cache_control` is Anthropic's prompt-caching breakpoint. It rides on the
4//! canonical model so a rebuilt request (system-prompt override, cross-wire
5//! translation) re-emits it exactly where the client placed it; wires without
6//! prompt caching ignore it.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use serde_json::Value;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Role {
15    System,
16    User,
17    Assistant,
18    Tool,
19}
20
21impl Role {
22    pub const fn as_str(self) -> &'static str {
23        match self {
24            Self::System => "system",
25            Self::User => "user",
26            Self::Assistant => "assistant",
27            Self::Tool => "tool",
28        }
29    }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ImageDetail {
34    Auto,
35    Low,
36    High,
37}
38
39impl ImageDetail {
40    pub const fn as_str(self) -> &'static str {
41        match self {
42            Self::Auto => "auto",
43            Self::Low => "low",
44            Self::High => "high",
45        }
46    }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum CacheTtl {
51    FiveMinutes,
52    OneHour,
53}
54
55impl CacheTtl {
56    pub const fn as_str(self) -> &'static str {
57        match self {
58            Self::FiveMinutes => "5m",
59            Self::OneHour => "1h",
60        }
61    }
62
63    #[must_use]
64    pub fn parse(value: &str) -> Option<Self> {
65        match value {
66            "5m" => Some(Self::FiveMinutes),
67            "1h" => Some(Self::OneHour),
68            _ => None,
69        }
70    }
71}
72
73// Why: Anthropic's only cache type is `ephemeral`; the breakpoint is the
74// block's presence, the TTL is the one optional knob.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76pub struct CacheControl {
77    pub ttl: Option<CacheTtl>,
78}
79
80impl CacheControl {
81    pub const EPHEMERAL: Self = Self { ttl: None };
82
83    #[must_use]
84    pub const fn with_ttl(ttl: CacheTtl) -> Self {
85        Self { ttl: Some(ttl) }
86    }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct SystemBlock {
91    pub text: String,
92    pub cache_control: Option<CacheControl>,
93}
94
95impl SystemBlock {
96    #[must_use]
97    pub fn text(text: impl Into<String>) -> Self {
98        Self {
99            text: text.into(),
100            cache_control: None,
101        }
102    }
103}
104
105#[derive(Debug, Clone)]
106pub enum ImageSource {
107    Base64 {
108        media_type: String,
109        data: String,
110        detail: Option<ImageDetail>,
111    },
112    Url {
113        url: String,
114        detail: Option<ImageDetail>,
115    },
116}
117
118#[derive(Debug, Clone)]
119pub enum CanonicalContent {
120    AnthropicToolBlock {
121        tool_name: String,
122        // JSON: Anthropic server-tool result blocks retain their upstream wire shape.
123        block: Value,
124    },
125    Text {
126        text: String,
127        cache_control: Option<CacheControl>,
128    },
129    Image {
130        source: ImageSource,
131        cache_control: Option<CacheControl>,
132    },
133    ToolUse {
134        id: String,
135        name: String,
136        // JSON: MCP tool-call arguments are the tool's own JSON object.
137        input: Value,
138        // Why: Gemini requires function-call `thoughtSignature` values replayed verbatim.
139        signature: Option<String>,
140        cache_control: Option<CacheControl>,
141    },
142    ToolResult {
143        tool_use_id: String,
144        content: Vec<Self>,
145        is_error: bool,
146        structured_content: Option<Value>,
147        // JSON: MCP `_meta` is an open map of vendor-prefixed keys.
148        meta: Option<Value>,
149        cache_control: Option<CacheControl>,
150    },
151    Thinking {
152        text: String,
153        signature: Option<String>,
154        // Why: OpenAI Responses requires the reasoning item ID and encrypted content
155        // replayed verbatim for stateless reasoning continuity.
156        id: Option<String>,
157        encrypted_content: Option<String>,
158    },
159}
160
161impl CanonicalContent {
162    #[must_use]
163    pub fn text(text: impl Into<String>) -> Self {
164        Self::Text {
165            text: text.into(),
166            cache_control: None,
167        }
168    }
169
170    #[must_use]
171    pub const fn image(source: ImageSource) -> Self {
172        Self::Image {
173            source,
174            cache_control: None,
175        }
176    }
177
178    // Why: thinking blocks cannot carry a breakpoint on the Messages API.
179    #[must_use]
180    pub const fn cache_control(&self) -> Option<CacheControl> {
181        match self {
182            Self::Text { cache_control, .. }
183            | Self::Image { cache_control, .. }
184            | Self::ToolUse { cache_control, .. }
185            | Self::ToolResult { cache_control, .. } => *cache_control,
186            Self::Thinking { .. } | Self::AnthropicToolBlock { .. } => None,
187        }
188    }
189}
190
191#[derive(Debug, Clone)]
192pub struct CanonicalMessage {
193    pub role: Role,
194    pub content: Vec<CanonicalContent>,
195}