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;