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: Gemini and some OpenAI-compatible providers report generic stop reasons
53    // alongside tool calls.
54    #[must_use]
55    pub const fn with_tool_use(self, has_tool_use: bool) -> Self {
56        match self {
57            Self::EndTurn | Self::Other if has_tool_use => Self::ToolUse,
58            other => other,
59        }
60    }
61
62    pub fn from_openai(s: &str) -> Self {
63        match s {
64            "stop" => Self::EndTurn,
65            "length" => Self::MaxTokens,
66            "tool_calls" | "function_call" => Self::ToolUse,
67            _ => Self::Other,
68        }
69    }
70}
71
72#[derive(Debug, Clone, Default)]
73pub struct GroundedSource {
74    pub uri: String,
75    pub title: Option<String>,
76    pub snippet: Option<String>,
77    pub relevance: Option<f32>,
78}
79
80#[derive(Debug, Clone, Default)]
81pub struct Grounding {
82    pub sources: Vec<GroundedSource>,
83    pub queries: Vec<String>,
84}
85
86#[derive(Debug, Clone, Default)]
87pub struct CodeExecutionOutput {
88    pub language: Option<String>,
89    pub code: String,
90    pub result: Option<String>,
91    pub outcome: Option<String>,
92}
93
94#[derive(Debug, Clone, Default)]
95pub struct CanonicalResponse {
96    pub id: String,
97    pub model: String,
98    pub content: Vec<CanonicalContent>,
99    pub stop_reason: Option<CanonicalStopReason>,
100    pub usage: CanonicalUsage,
101    pub grounding: Option<Grounding>,
102    pub code_execution: Option<CodeExecutionOutput>,
103    pub raw_finish_reason: Option<String>,
104    pub received_surface: ForwardedSurface,
105}
106
107impl CanonicalResponse {
108    pub fn content_units(&self) -> Vec<String> {
109        let mut units = Vec::with_capacity(self.content.len() + self.received_surface.len());
110        for part in &self.content {
111            let mut out = String::new();
112            flatten_part(&mut out, part);
113            if !out.is_empty() {
114                units.push(out);
115            }
116        }
117        for leaf in self.received_surface.leaves() {
118            units.push(leaf.value.clone());
119        }
120        units
121    }
122}
123
124#[derive(Debug, Clone)]
125pub enum CanonicalEvent {
126    MessageStart {
127        id: String,
128        model: String,
129        usage: CanonicalUsage,
130    },
131    ContentBlockStart {
132        index: u32,
133        block: ContentBlockKind,
134    },
135    TextDelta {
136        index: u32,
137        text: String,
138    },
139    ThinkingDelta {
140        index: u32,
141        text: String,
142    },
143    SignatureDelta {
144        index: u32,
145        signature: String,
146    },
147    EncryptedContentDelta {
148        index: u32,
149        data: String,
150    },
151    ToolUseDelta {
152        index: u32,
153        partial_json: String,
154    },
155    ContentBlockStop {
156        index: u32,
157    },
158    UsageDelta(CanonicalUsageUpdate),
159    MessageStop {
160        id: String,
161        stop_reason: Option<CanonicalStopReason>,
162    },
163    Error(String),
164}
165
166#[derive(Debug, Clone)]
167pub enum ContentBlockKind {
168    Text,
169    Thinking {
170        id: Option<String>,
171        signature: Option<String>,
172    },
173    ToolUse {
174        id: String,
175        name: String,
176        signature: Option<String>,
177    },
178}