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 super::usage::{CanonicalUsage, CanonicalUsageUpdate};
13use crate::wire::inspect::ForwardedSurface;
14
15/// Why the upstream model stopped, in provider-neutral terms.
16///
17/// `Refusal` is the model (or its safety layer) declining to continue —
18/// Anthropic `refusal`, `OpenAI` `content_filter`, Gemini `SAFETY` and its
19/// siblings. `Other` is reserved for a reason no dialect classifies; a turn
20/// that ends on it *with* content still relays as a clean stop, while one
21/// that ends on it with nothing is an upstream error, and the raw reason is
22/// carried beside it so nothing is masked on the way to the audit row.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum CanonicalStopReason {
25    EndTurn,
26    MaxTokens,
27    StopSequence,
28    ToolUse,
29    Refusal,
30    Other,
31}
32
33impl CanonicalStopReason {
34    pub const fn anthropic_str(self) -> &'static str {
35        match self {
36            Self::MaxTokens => "max_tokens",
37            Self::StopSequence => "stop_sequence",
38            Self::ToolUse => "tool_use",
39            Self::Refusal => "refusal",
40            Self::EndTurn | Self::Other => "end_turn",
41        }
42    }
43
44    pub const fn openai_str(self) -> &'static str {
45        match self {
46            Self::MaxTokens => "length",
47            Self::ToolUse => "tool_calls",
48            Self::Refusal => "content_filter",
49            Self::EndTurn | Self::StopSequence | Self::Other => "stop",
50        }
51    }
52
53    // Why: a provider that cut the turn off (refusal, an unknown reason)
54    // must not relay as a clean empty turn; only "nothing to say" (`STOP`,
55    // an exhausted budget) is a legitimate empty terminal.
56    #[must_use]
57    pub const fn empty_terminal_is_error(self) -> bool {
58        matches!(self, Self::Refusal | Self::Other)
59    }
60
61    pub fn from_anthropic(s: &str) -> Self {
62        match s {
63            "end_turn" => Self::EndTurn,
64            "max_tokens" => Self::MaxTokens,
65            "stop_sequence" => Self::StopSequence,
66            "tool_use" => Self::ToolUse,
67            "refusal" => Self::Refusal,
68            _ => Self::Other,
69        }
70    }
71
72    // Why: Gemini and some OpenAI-compatible providers report generic stop reasons
73    // alongside tool calls.
74    #[must_use]
75    pub const fn with_tool_use(self, has_tool_use: bool) -> Self {
76        match self {
77            Self::EndTurn | Self::Other if has_tool_use => Self::ToolUse,
78            other => other,
79        }
80    }
81
82    pub fn from_openai(s: &str) -> Self {
83        match s {
84            "stop" => Self::EndTurn,
85            "length" => Self::MaxTokens,
86            "tool_calls" | "function_call" => Self::ToolUse,
87            "content_filter" => Self::Refusal,
88            _ => Self::Other,
89        }
90    }
91}
92
93#[derive(Debug, Clone, Default)]
94pub struct GroundedSource {
95    pub uri: String,
96    pub title: Option<String>,
97    pub snippet: Option<String>,
98    pub relevance: Option<f32>,
99}
100
101#[derive(Debug, Clone, Default)]
102pub struct Grounding {
103    pub sources: Vec<GroundedSource>,
104    pub queries: Vec<String>,
105}
106
107#[derive(Debug, Clone, Default)]
108pub struct CodeExecutionOutput {
109    pub language: Option<String>,
110    pub code: String,
111    pub result: Option<String>,
112    pub outcome: Option<String>,
113}
114
115#[derive(Debug, Clone, Default)]
116pub struct CanonicalResponse {
117    pub id: String,
118    pub model: String,
119    pub content: Vec<CanonicalContent>,
120    pub stop_reason: Option<CanonicalStopReason>,
121    pub usage: CanonicalUsage,
122    pub grounding: Option<Grounding>,
123    pub code_execution: Option<CodeExecutionOutput>,
124    pub raw_finish_reason: Option<String>,
125    pub received_surface: ForwardedSurface,
126}
127
128impl CanonicalResponse {
129    pub fn content_units(&self) -> Vec<String> {
130        let mut units = Vec::with_capacity(self.content.len() + self.received_surface.len());
131        for part in &self.content {
132            let mut out = String::new();
133            flatten_part(&mut out, part);
134            if !out.is_empty() {
135                units.push(out);
136            }
137        }
138        for leaf in self.received_surface.leaves() {
139            units.push(leaf.value.clone());
140        }
141        units
142    }
143}
144
145#[derive(Debug, Clone)]
146pub enum CanonicalEvent {
147    MessageStart {
148        id: String,
149        model: String,
150        usage: CanonicalUsage,
151    },
152    ContentBlockStart {
153        index: u32,
154        block: ContentBlockKind,
155    },
156    TextDelta {
157        index: u32,
158        text: String,
159    },
160    ThinkingDelta {
161        index: u32,
162        text: String,
163    },
164    SignatureDelta {
165        index: u32,
166        signature: String,
167    },
168    EncryptedContentDelta {
169        index: u32,
170        data: String,
171    },
172    ToolUseDelta {
173        index: u32,
174        partial_json: String,
175    },
176    ContentBlockStop {
177        index: u32,
178    },
179    UsageDelta(CanonicalUsageUpdate),
180    MessageStop {
181        id: String,
182        stop_reason: Option<CanonicalStopReason>,
183        raw_finish_reason: Option<String>,
184    },
185    Error(String),
186}
187
188#[derive(Debug, Clone)]
189pub enum ContentBlockKind {
190    Text,
191    Thinking {
192        id: Option<String>,
193        signature: Option<String>,
194    },
195    ToolUse {
196        id: String,
197        name: String,
198        signature: Option<String>,
199    },
200}