Skip to main content

open_agent/types/
anthropic_stream.rs

1//! Wire types for the Anthropic messages streaming response.
2//!
3//! Anthropic streams a typed event sequence rather than OpenAI's uniform chunk-with-deltas:
4//! blocks are opened, appended to, and closed by index, and the reason generation stopped
5//! arrives on a `message_delta` near the end. Every event carries its own `type`, so the SSE
6//! `event:` line is redundant and the SDK parses only the `data:` payload.
7//!
8//! Every enum here has an `Unknown` catch-all. Third-party Anthropic-compatible endpoints
9//! emit events this SDK has never heard of, and a hard parse failure on one of them would
10//! discard a response that was otherwise complete.
11
12use serde::Deserialize;
13
14use super::FinishReason;
15
16/// One event from an Anthropic streaming response.
17#[derive(Debug, Clone, Deserialize)]
18#[serde(tag = "type", rename_all = "snake_case")]
19#[non_exhaustive]
20pub enum AnthropicEvent {
21    /// Opens the response. Carries usage and the empty message envelope, neither of which
22    /// the SDK needs.
23    MessageStart {},
24
25    /// Opens a content block at `index`, declaring what kind it is.
26    ContentBlockStart {
27        /// Position of the block within the response.
28        index: u32,
29        /// The block's kind, and its identity when it is a tool call.
30        content_block: AnthropicBlockStart,
31    },
32
33    /// Appends to the block at `index`.
34    ContentBlockDelta {
35        /// Position of the block being appended to.
36        index: u32,
37        /// The fragment to append, tagged by which channel it belongs to.
38        delta: AnthropicDelta,
39    },
40
41    /// Closes the block at `index`.
42    ContentBlockStop {
43        /// Position of the block being closed.
44        index: u32,
45    },
46
47    /// Reports top-level message changes; this is where `stop_reason` arrives.
48    MessageDelta {
49        /// The changed fields.
50        delta: AnthropicMessageDelta,
51    },
52
53    /// Ends the response.
54    MessageStop {},
55
56    /// Keep-alive. Carries nothing.
57    Ping {},
58
59    /// A mid-stream error, such as an overload. Terminates the response.
60    Error {
61        /// The error body.
62        error: AnthropicErrorBody,
63    },
64
65    /// An event type this SDK does not recognise, ignored rather than fatal.
66    #[serde(other)]
67    Unknown,
68}
69
70/// The declaration that opens a content block.
71#[derive(Debug, Clone, Deserialize)]
72#[serde(tag = "type", rename_all = "snake_case")]
73#[non_exhaustive]
74pub enum AnthropicBlockStart {
75    /// Assistant text.
76    Text {
77        /// Text present at the point the block opened. Normally empty; some compatible
78        /// servers front-load the first fragment here rather than sending a delta for it,
79        /// and dropping it would lose the opening characters of the answer.
80        #[serde(default)]
81        text: String,
82    },
83
84    /// Extended thinking, which belongs to the reasoning channel and never to content.
85    Thinking {
86        /// Thinking text present at the point the block opened, for the same reason as
87        /// [`AnthropicBlockStart::Text`].
88        #[serde(default)]
89        thinking: String,
90    },
91
92    /// Thinking the server has redacted. Carries ciphertext, never plain reasoning, so it is
93    /// tracked as a block kind and its payload discarded.
94    RedactedThinking {},
95
96    /// A tool call. Its arguments arrive later as `input_json_delta` fragments.
97    ToolUse {
98        /// Correlation id, echoed back with the tool result.
99        id: String,
100        /// Name of the tool the model wants to run.
101        name: String,
102    },
103
104    /// A block kind this SDK does not recognise. It carries no id or name to emit under, so
105    /// the block itself contributes nothing; its deltas are still routed by their own tag,
106    /// which is what keeps an unrecognised channel out of assistant text.
107    #[serde(other)]
108    Unknown,
109}
110
111/// One fragment appended to an open block.
112#[derive(Debug, Clone, Deserialize)]
113#[serde(tag = "type", rename_all = "snake_case")]
114#[non_exhaustive]
115pub enum AnthropicDelta {
116    /// A fragment of assistant text.
117    TextDelta {
118        /// The fragment.
119        text: String,
120    },
121
122    /// A fragment of extended thinking.
123    ThinkingDelta {
124        /// The fragment.
125        thinking: String,
126    },
127
128    /// The cryptographic signature over a thinking block. Not reasoning text and not
129    /// content, so it is parsed and dropped.
130    SignatureDelta {},
131
132    /// A fragment of a tool call's JSON arguments. Split at arbitrary byte positions.
133    InputJsonDelta {
134        /// The fragment.
135        partial_json: String,
136    },
137
138    /// A delta type this SDK does not recognise.
139    #[serde(other)]
140    Unknown,
141}
142
143/// Top-level message changes, carrying the reason generation stopped.
144#[derive(Debug, Clone, Deserialize)]
145pub struct AnthropicMessageDelta {
146    /// Why generation stopped. Null until the model actually stops.
147    #[serde(default)]
148    pub stop_reason: Option<String>,
149}
150
151/// The body of a mid-stream `error` event.
152#[derive(Debug, Clone, Deserialize)]
153pub struct AnthropicErrorBody {
154    /// The error's machine-readable kind, e.g. `"overloaded_error"`.
155    #[serde(rename = "type", default)]
156    pub error_type: Option<String>,
157
158    /// Human-readable description.
159    #[serde(default)]
160    pub message: String,
161}
162
163/// Maps an Anthropic `stop_reason` onto the SDK's protocol-neutral [`FinishReason`].
164///
165/// Anthropic and OpenAI agree on none of the spellings, so the OpenAI-shaped
166/// [`FinishReason::from_wire`] would file every one of these under
167/// [`FinishReason::Other`] and callers branching on `Length` would never see a truncation.
168///
169/// `model_context_window_exceeded` maps to [`FinishReason::Length`] because it is a token
170/// ceiling like any other, and a caller's correct response — send less, do not simply ask
171/// again — is the same one `Length` already prescribes. `pause_turn` keeps its own name:
172/// the turn is resumable, which no existing variant means, and inventing an equivalence
173/// would tell a caller the response finished when it did not.
174///
175/// # Examples
176///
177/// ```rust
178/// use open_agent::FinishReason;
179/// use open_agent::anthropic_finish_reason;
180///
181/// assert_eq!(anthropic_finish_reason("end_turn"), FinishReason::Stop);
182/// assert_eq!(anthropic_finish_reason("max_tokens"), FinishReason::Length);
183/// ```
184pub fn anthropic_finish_reason(raw: &str) -> FinishReason {
185    match raw.to_ascii_lowercase().as_str() {
186        "end_turn" | "stop_sequence" => FinishReason::Stop,
187        "max_tokens" | "model_context_window_exceeded" => FinishReason::Length,
188        "tool_use" => FinishReason::ToolCalls,
189        "refusal" => FinishReason::ContentFilter,
190        _ => FinishReason::Other(raw.to_string()),
191    }
192}
193
194#[cfg(test)]
195mod tests;