Skip to main content

systemprompt_models/wire/canonical/
response.rs

1//! The provider-neutral response and streaming-event model.
2//!
3//! Outbound adapters parse a buffered upstream reply into a
4//! [`CanonicalResponse`] or map upstream SSE bytes to a stream of
5//! [`CanonicalEvent`]s. Stop reasons are normalised here, with per-dialect
6//! string mappings.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use super::request::{CanonicalContent, flatten_part};
12use crate::wire::inspect::ForwardedSurface;
13
14#[derive(Debug, Clone, Copy, Default)]
15#[expect(
16    clippy::struct_field_names,
17    reason = "every field is a token count; the `_tokens` suffix is the domain vocabulary shared \
18              with the provider usage wire formats"
19)]
20pub struct CanonicalUsage {
21    pub input_tokens: u32,
22    pub output_tokens: u32,
23    pub cache_read_tokens: u32,
24    pub cache_creation_tokens: u32,
25    pub total_tokens: u32,
26}
27
28/// A streaming usage report, carrying only the counts its frame actually
29/// stated.
30///
31/// [`CanonicalUsage`] cannot express this: an unreported count and a reported
32/// zero are both `0`. Providers differ in what a mid-stream usage frame
33/// includes — an Anthropic `message_delta` may carry `output_tokens` alone —
34/// so folding one in as though it were complete zeroes the input and cache
35/// counts an earlier frame established, and billing loses them.
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
37#[expect(
38    clippy::struct_field_names,
39    reason = "every field is a token count; the `_tokens` suffix is the domain vocabulary shared \
40              with the provider usage wire formats"
41)]
42pub struct CanonicalUsageUpdate {
43    pub input_tokens: Option<u32>,
44    pub output_tokens: Option<u32>,
45    pub cache_read_tokens: Option<u32>,
46    pub cache_creation_tokens: Option<u32>,
47}
48
49impl CanonicalUsageUpdate {
50    #[must_use]
51    pub const fn is_empty(&self) -> bool {
52        self.input_tokens.is_none()
53            && self.output_tokens.is_none()
54            && self.cache_read_tokens.is_none()
55            && self.cache_creation_tokens.is_none()
56    }
57
58    /// Overwrite every count this update states, leaving the rest as they were,
59    /// and rederive the total. A stated zero overwrites — it is a real report,
60    /// which is the whole distinction this type exists to carry.
61    pub const fn apply_to(&self, usage: &mut CanonicalUsage) {
62        if let Some(v) = self.input_tokens {
63            usage.input_tokens = v;
64        }
65        if let Some(v) = self.output_tokens {
66            usage.output_tokens = v;
67        }
68        if let Some(v) = self.cache_read_tokens {
69            usage.cache_read_tokens = v;
70        }
71        if let Some(v) = self.cache_creation_tokens {
72            usage.cache_creation_tokens = v;
73        }
74        usage.total_tokens = usage.input_tokens
75            + usage.output_tokens
76            + usage.cache_read_tokens
77            + usage.cache_creation_tokens;
78    }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum CanonicalStopReason {
83    EndTurn,
84    MaxTokens,
85    StopSequence,
86    ToolUse,
87    Other,
88}
89
90impl CanonicalStopReason {
91    pub const fn anthropic_str(self) -> &'static str {
92        match self {
93            Self::MaxTokens => "max_tokens",
94            Self::StopSequence => "stop_sequence",
95            Self::ToolUse => "tool_use",
96            Self::EndTurn | Self::Other => "end_turn",
97        }
98    }
99
100    pub const fn openai_str(self) -> &'static str {
101        match self {
102            Self::MaxTokens => "length",
103            Self::ToolUse => "tool_calls",
104            Self::EndTurn | Self::StopSequence | Self::Other => "stop",
105        }
106    }
107
108    pub fn from_anthropic(s: &str) -> Self {
109        match s {
110            "end_turn" => Self::EndTurn,
111            "max_tokens" => Self::MaxTokens,
112            "stop_sequence" => Self::StopSequence,
113            "tool_use" => Self::ToolUse,
114            _ => Self::Other,
115        }
116    }
117
118    pub fn from_openai(s: &str) -> Self {
119        match s {
120            "stop" => Self::EndTurn,
121            "length" => Self::MaxTokens,
122            "tool_calls" | "function_call" => Self::ToolUse,
123            _ => Self::Other,
124        }
125    }
126}
127
128#[derive(Debug, Clone, Default)]
129pub struct GroundedSource {
130    pub uri: String,
131    pub title: Option<String>,
132    pub snippet: Option<String>,
133    pub relevance: Option<f32>,
134}
135
136#[derive(Debug, Clone, Default)]
137pub struct Grounding {
138    pub sources: Vec<GroundedSource>,
139    pub queries: Vec<String>,
140}
141
142#[derive(Debug, Clone, Default)]
143pub struct CodeExecutionOutput {
144    pub language: Option<String>,
145    pub code: String,
146    pub result: Option<String>,
147    pub outcome: Option<String>,
148}
149
150#[derive(Debug, Clone, Default)]
151pub struct CanonicalResponse {
152    pub id: String,
153    pub model: String,
154    pub content: Vec<CanonicalContent>,
155    pub stop_reason: Option<CanonicalStopReason>,
156    pub usage: CanonicalUsage,
157    pub grounding: Option<Grounding>,
158    pub code_execution: Option<CodeExecutionOutput>,
159    pub raw_finish_reason: Option<String>,
160    /// Every string in the bytes the client receives.
161    ///
162    /// The canonical form is lossy in the same way it is on the request side,
163    /// so the gateway fills this from the rendered response body — buffered
164    /// bytes directly, streamed bytes via
165    /// [`sse_string_leaves`](crate::wire::inspect::sse_string_leaves) — before
166    /// any response scan runs. Callers that never serve through the gateway
167    /// leave it empty.
168    pub received_surface: ForwardedSurface,
169}
170
171impl CanonicalResponse {
172    /// Each content block and each surface leaf is its own unit: concatenating
173    /// them would let two unrelated strings splice into a match neither one
174    /// contains.
175    pub fn content_units(&self) -> Vec<String> {
176        let mut units = Vec::with_capacity(self.content.len() + self.received_surface.len());
177        for part in &self.content {
178            let mut out = String::new();
179            flatten_part(&mut out, part);
180            if !out.is_empty() {
181                units.push(out);
182            }
183        }
184        for leaf in self.received_surface.leaves() {
185            units.push(leaf.value.clone());
186        }
187        units
188    }
189}
190
191#[derive(Debug, Clone)]
192pub enum CanonicalEvent {
193    MessageStart {
194        id: String,
195        model: String,
196        usage: CanonicalUsage,
197    },
198    ContentBlockStart {
199        index: u32,
200        block: ContentBlockKind,
201    },
202    TextDelta {
203        index: u32,
204        text: String,
205    },
206    ThinkingDelta {
207        index: u32,
208        text: String,
209    },
210    SignatureDelta {
211        index: u32,
212        signature: String,
213    },
214    EncryptedContentDelta {
215        index: u32,
216        data: String,
217    },
218    ToolUseDelta {
219        index: u32,
220        partial_json: String,
221    },
222    ContentBlockStop {
223        index: u32,
224    },
225    UsageDelta(CanonicalUsageUpdate),
226    MessageStop {
227        id: String,
228        stop_reason: Option<CanonicalStopReason>,
229    },
230    Error(String),
231}
232
233#[derive(Debug, Clone)]
234pub enum ContentBlockKind {
235    Text,
236    Thinking {
237        id: Option<String>,
238        signature: Option<String>,
239    },
240    ToolUse {
241        id: String,
242        name: String,
243        signature: Option<String>,
244    },
245}