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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum CanonicalStopReason {
17    EndTurn,
18    MaxTokens,
19    StopSequence,
20    ToolUse,
21    Other,
22}
23
24impl CanonicalStopReason {
25    pub const fn anthropic_str(self) -> &'static str {
26        match self {
27            Self::MaxTokens => "max_tokens",
28            Self::StopSequence => "stop_sequence",
29            Self::ToolUse => "tool_use",
30            Self::EndTurn | Self::Other => "end_turn",
31        }
32    }
33
34    pub const fn openai_str(self) -> &'static str {
35        match self {
36            Self::MaxTokens => "length",
37            Self::ToolUse => "tool_calls",
38            Self::EndTurn | Self::StopSequence | Self::Other => "stop",
39        }
40    }
41
42    pub fn from_anthropic(s: &str) -> Self {
43        match s {
44            "end_turn" => Self::EndTurn,
45            "max_tokens" => Self::MaxTokens,
46            "stop_sequence" => Self::StopSequence,
47            "tool_use" => Self::ToolUse,
48            _ => Self::Other,
49        }
50    }
51
52    // Why: providers routinely report a generic "stop" beside a fully-formed
53    // tool call -- Gemini sends `finishReason: STOP` on a functionCall
54    // candidate, several OpenAI-compatible upstreams send `finish_reason:
55    // "stop"` beside a tool_calls array. Relayed verbatim, every client ends
56    // the turn and the call is silently never run. Truncation still wins: a
57    // call cut mid-arguments carries unparseable JSON, so declaring tool use
58    // there hands the client a call it cannot run.
59    #[must_use]
60    pub const fn with_tool_use(self, has_tool_use: bool) -> Self {
61        match self {
62            Self::EndTurn | Self::Other if has_tool_use => Self::ToolUse,
63            other => other,
64        }
65    }
66
67    pub fn from_openai(s: &str) -> Self {
68        match s {
69            "stop" => Self::EndTurn,
70            "length" => Self::MaxTokens,
71            "tool_calls" | "function_call" => Self::ToolUse,
72            _ => Self::Other,
73        }
74    }
75}
76
77#[derive(Debug, Clone, Default)]
78pub struct GroundedSource {
79    pub uri: String,
80    pub title: Option<String>,
81    pub snippet: Option<String>,
82    pub relevance: Option<f32>,
83}
84
85#[derive(Debug, Clone, Default)]
86pub struct Grounding {
87    pub sources: Vec<GroundedSource>,
88    pub queries: Vec<String>,
89}
90
91#[derive(Debug, Clone, Default)]
92pub struct CodeExecutionOutput {
93    pub language: Option<String>,
94    pub code: String,
95    pub result: Option<String>,
96    pub outcome: Option<String>,
97}
98
99#[derive(Debug, Clone, Default)]
100pub struct CanonicalResponse {
101    pub id: String,
102    pub model: String,
103    pub content: Vec<CanonicalContent>,
104    pub stop_reason: Option<CanonicalStopReason>,
105    pub usage: CanonicalUsage,
106    pub grounding: Option<Grounding>,
107    pub code_execution: Option<CodeExecutionOutput>,
108    pub raw_finish_reason: Option<String>,
109    pub received_surface: ForwardedSurface,
110}
111
112impl CanonicalResponse {
113    pub fn content_units(&self) -> Vec<String> {
114        let mut units = Vec::with_capacity(self.content.len() + self.received_surface.len());
115        for part in &self.content {
116            let mut out = String::new();
117            flatten_part(&mut out, part);
118            if !out.is_empty() {
119                units.push(out);
120            }
121        }
122        for leaf in self.received_surface.leaves() {
123            units.push(leaf.value.clone());
124        }
125        units
126    }
127}
128
129#[derive(Debug, Clone)]
130pub enum CanonicalEvent {
131    MessageStart {
132        id: String,
133        model: String,
134        usage: CanonicalUsage,
135    },
136    ContentBlockStart {
137        index: u32,
138        block: ContentBlockKind,
139    },
140    TextDelta {
141        index: u32,
142        text: String,
143    },
144    ThinkingDelta {
145        index: u32,
146        text: String,
147    },
148    SignatureDelta {
149        index: u32,
150        signature: String,
151    },
152    EncryptedContentDelta {
153        index: u32,
154        data: String,
155    },
156    ToolUseDelta {
157        index: u32,
158        partial_json: String,
159    },
160    ContentBlockStop {
161        index: u32,
162    },
163    UsageDelta(CanonicalUsageUpdate),
164    MessageStop {
165        id: String,
166        stop_reason: Option<CanonicalStopReason>,
167    },
168    Error(String),
169}
170
171#[derive(Debug, Clone)]
172pub enum ContentBlockKind {
173    Text,
174    Thinking {
175        id: Option<String>,
176        signature: Option<String>,
177    },
178    ToolUse {
179        id: String,
180        name: String,
181        signature: Option<String>,
182    },
183}