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    pub const fn apply_to(&self, usage: &mut CanonicalUsage) {
59        if let Some(v) = self.input_tokens {
60            usage.input_tokens = v;
61        }
62        if let Some(v) = self.output_tokens {
63            usage.output_tokens = v;
64        }
65        if let Some(v) = self.cache_read_tokens {
66            usage.cache_read_tokens = v;
67        }
68        if let Some(v) = self.cache_creation_tokens {
69            usage.cache_creation_tokens = v;
70        }
71        usage.total_tokens = usage.input_tokens
72            + usage.output_tokens
73            + usage.cache_read_tokens
74            + usage.cache_creation_tokens;
75    }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum CanonicalStopReason {
80    EndTurn,
81    MaxTokens,
82    StopSequence,
83    ToolUse,
84    Other,
85}
86
87impl CanonicalStopReason {
88    pub const fn anthropic_str(self) -> &'static str {
89        match self {
90            Self::MaxTokens => "max_tokens",
91            Self::StopSequence => "stop_sequence",
92            Self::ToolUse => "tool_use",
93            Self::EndTurn | Self::Other => "end_turn",
94        }
95    }
96
97    pub const fn openai_str(self) -> &'static str {
98        match self {
99            Self::MaxTokens => "length",
100            Self::ToolUse => "tool_calls",
101            Self::EndTurn | Self::StopSequence | Self::Other => "stop",
102        }
103    }
104
105    pub fn from_anthropic(s: &str) -> Self {
106        match s {
107            "end_turn" => Self::EndTurn,
108            "max_tokens" => Self::MaxTokens,
109            "stop_sequence" => Self::StopSequence,
110            "tool_use" => Self::ToolUse,
111            _ => Self::Other,
112        }
113    }
114
115    pub fn from_openai(s: &str) -> Self {
116        match s {
117            "stop" => Self::EndTurn,
118            "length" => Self::MaxTokens,
119            "tool_calls" | "function_call" => Self::ToolUse,
120            _ => Self::Other,
121        }
122    }
123}
124
125#[derive(Debug, Clone, Default)]
126pub struct GroundedSource {
127    pub uri: String,
128    pub title: Option<String>,
129    pub snippet: Option<String>,
130    pub relevance: Option<f32>,
131}
132
133#[derive(Debug, Clone, Default)]
134pub struct Grounding {
135    pub sources: Vec<GroundedSource>,
136    pub queries: Vec<String>,
137}
138
139#[derive(Debug, Clone, Default)]
140pub struct CodeExecutionOutput {
141    pub language: Option<String>,
142    pub code: String,
143    pub result: Option<String>,
144    pub outcome: Option<String>,
145}
146
147#[derive(Debug, Clone, Default)]
148pub struct CanonicalResponse {
149    pub id: String,
150    pub model: String,
151    pub content: Vec<CanonicalContent>,
152    pub stop_reason: Option<CanonicalStopReason>,
153    pub usage: CanonicalUsage,
154    pub grounding: Option<Grounding>,
155    pub code_execution: Option<CodeExecutionOutput>,
156    pub raw_finish_reason: Option<String>,
157    pub received_surface: ForwardedSurface,
158}
159
160impl CanonicalResponse {
161    pub fn content_units(&self) -> Vec<String> {
162        let mut units = Vec::with_capacity(self.content.len() + self.received_surface.len());
163        for part in &self.content {
164            let mut out = String::new();
165            flatten_part(&mut out, part);
166            if !out.is_empty() {
167                units.push(out);
168            }
169        }
170        for leaf in self.received_surface.leaves() {
171            units.push(leaf.value.clone());
172        }
173        units
174    }
175}
176
177#[derive(Debug, Clone)]
178pub enum CanonicalEvent {
179    MessageStart {
180        id: String,
181        model: String,
182        usage: CanonicalUsage,
183    },
184    ContentBlockStart {
185        index: u32,
186        block: ContentBlockKind,
187    },
188    TextDelta {
189        index: u32,
190        text: String,
191    },
192    ThinkingDelta {
193        index: u32,
194        text: String,
195    },
196    SignatureDelta {
197        index: u32,
198        signature: String,
199    },
200    EncryptedContentDelta {
201        index: u32,
202        data: String,
203    },
204    ToolUseDelta {
205        index: u32,
206        partial_json: String,
207    },
208    ContentBlockStop {
209        index: u32,
210    },
211    UsageDelta(CanonicalUsageUpdate),
212    MessageStop {
213        id: String,
214        stop_reason: Option<CanonicalStopReason>,
215    },
216    Error(String),
217}
218
219#[derive(Debug, Clone)]
220pub enum ContentBlockKind {
221    Text,
222    Thinking {
223        id: Option<String>,
224        signature: Option<String>,
225    },
226    ToolUse {
227        id: String,
228        name: String,
229        signature: Option<String>,
230    },
231}