Skip to main content

qcli/provider/
reply.rs

1//! A streamed reply in the Anthropic Messages shape, put together as it arrives.
2//!
3//! The server sends server-sent events: `event:` and `data:` lines, a blank line between events.
4//! Only the `data` matters, since each JSON object names its own type. Text and thinking are
5//! handed on piece by piece as they come, so the screen can show them growing; a tool call is
6//! handed on once its name is known, and its input, which arrives as fragments of JSON, is read
7//! when its block ends.
8
9use std::io::BufRead;
10
11use serde_json::{Map, Value, json};
12
13use super::ProviderError;
14
15/// Something that arrived while a reply streams in.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Delta {
18    /// More of the answer's text.
19    Text(String),
20    /// More of the model's thinking, for providers that show it.
21    Thinking(String),
22    /// The model began a tool call with this name.
23    ToolCall(String),
24}
25
26/// Why the model stopped.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum Stop {
29    /// It finished its turn.
30    EndTurn,
31    /// It asked for tools and waits for their results.
32    ToolUse,
33    /// It ran out of the tokens it was allowed to write.
34    MaxTokens,
35    /// Anything else the server named, kept as it was said.
36    Other(String),
37}
38
39/// A whole reply.
40#[derive(Debug, Clone, PartialEq)]
41pub struct Reply {
42    /// The content blocks in the Messages shape, ready to go back into the conversation as the
43    /// assistant's message: `text`, `thinking` and `tool_use` blocks.
44    pub content: Vec<Value>,
45    /// Why the model stopped.
46    pub stop: Stop,
47    /// The input tokens the server counted, when it said.
48    pub input_tokens: Option<u64>,
49    /// The output tokens the server counted, when it said.
50    pub output_tokens: Option<u64>,
51}
52
53/// One content block while it is being written.
54#[derive(Debug)]
55struct Block {
56    value: Value,
57    /// The fragments of a tool call's input, joined when the block ends.
58    input: String,
59}
60
61/// Reads the event stream in `lines` to its end and puts the reply together, handing each piece
62/// to `on_delta` as it comes. `cancelled` is asked between events; when it says yes the reply
63/// read so far is returned as it is.
64///
65/// # Errors
66///
67/// When the server sends an error event, when the stream breaks, or when it ends without saying
68/// the message is complete.
69pub fn read(
70    lines: impl BufRead,
71    on_delta: &mut dyn FnMut(Delta),
72    cancelled: &dyn Fn() -> bool,
73) -> Result<Reply, ProviderError> {
74    let mut blocks: Vec<Block> = Vec::new();
75    let mut reply = Reply { content: Vec::new(), stop: Stop::EndTurn, input_tokens: None, output_tokens: None };
76    let mut data = String::new();
77    let mut complete = false;
78    for line in lines.lines() {
79        let line = line.map_err(|error| ProviderError::Unreadable { what: error.to_string() })?;
80        if let Some(rest) = line.strip_prefix("data:") {
81            data.push_str(rest.strip_prefix(' ').unwrap_or(rest));
82            continue;
83        }
84        if !line.is_empty() || data.is_empty() {
85            continue;
86        }
87        let event = std::mem::take(&mut data);
88        if event == "[DONE]" {
89            complete = true;
90            break;
91        }
92        let event: Value = serde_json::from_str(&event)
93            .map_err(|error| ProviderError::Unreadable { what: format!("{error}: {event}") })?;
94        if apply(&event, &mut blocks, &mut reply, on_delta)? {
95            complete = true;
96            break;
97        }
98        if cancelled() {
99            complete = true;
100            break;
101        }
102    }
103    if !complete {
104        return Err(ProviderError::Unreadable { what: "the reply ended before the message was complete".to_owned() });
105    }
106    reply.content = blocks.into_iter().map(finish).collect();
107    Ok(reply)
108}
109
110/// Applies one event and says whether it was the last.
111fn apply(
112    event: &Value,
113    blocks: &mut Vec<Block>,
114    reply: &mut Reply,
115    on_delta: &mut dyn FnMut(Delta),
116) -> Result<bool, ProviderError> {
117    let kind = event.get("type").and_then(Value::as_str).unwrap_or_default();
118    match kind {
119        "message_start" => {
120            let usage = event.pointer("/message/usage");
121            reply.input_tokens = usage.and_then(|usage| usage.get("input_tokens")).and_then(Value::as_u64);
122        }
123        "content_block_start" => {
124            let block = event.get("content_block").cloned().unwrap_or_else(|| json!({"type": "text", "text": ""}));
125            if block.get("type").and_then(Value::as_str) == Some("tool_use") {
126                on_delta(Delta::ToolCall(block.get("name").and_then(Value::as_str).unwrap_or_default().to_owned()));
127            }
128            blocks.push(Block { value: block, input: String::new() });
129        }
130        "content_block_delta" => {
131            let Some(block) = blocks.last_mut() else {
132                return Err(ProviderError::Unreadable { what: "a delta arrived before any block".to_owned() });
133            };
134            let delta = event.get("delta").cloned().unwrap_or_default();
135            let text = |field: &str| delta.get(field).and_then(Value::as_str).unwrap_or_default().to_owned();
136            match delta.get("type").and_then(Value::as_str).unwrap_or_default() {
137                "text_delta" => {
138                    let piece = text("text");
139                    append(&mut block.value, "text", &piece);
140                    on_delta(Delta::Text(piece));
141                }
142                "thinking_delta" => {
143                    let piece = text("thinking");
144                    append(&mut block.value, "thinking", &piece);
145                    on_delta(Delta::Thinking(piece));
146                }
147                "signature_delta" => append(&mut block.value, "signature", &text("signature")),
148                "input_json_delta" => block.input.push_str(&text("partial_json")),
149                _ => {}
150            }
151        }
152        "message_delta" => {
153            if let Some(stop) = event.pointer("/delta/stop_reason").and_then(Value::as_str) {
154                reply.stop = match stop {
155                    "end_turn" | "stop_sequence" => Stop::EndTurn,
156                    "tool_use" => Stop::ToolUse,
157                    "max_tokens" => Stop::MaxTokens,
158                    other => Stop::Other(other.to_owned()),
159                };
160            }
161            if let Some(tokens) = event.pointer("/usage/output_tokens").and_then(Value::as_u64) {
162                reply.output_tokens = Some(tokens);
163            }
164        }
165        "message_stop" => return Ok(true),
166        "error" => {
167            let said = event.pointer("/error/message").and_then(Value::as_str).unwrap_or("error").to_owned();
168            return Err(ProviderError::Stopped { said });
169        }
170        _ => {}
171    }
172    Ok(false)
173}
174
175/// Adds `piece` to the text field `field` of `block`.
176fn append(block: &mut Value, field: &str, piece: &str) {
177    if let Some(object) = block.as_object_mut() {
178        let mut text = object.get(field).and_then(Value::as_str).unwrap_or_default().to_owned();
179        text.push_str(piece);
180        object.insert(field.to_owned(), Value::String(text));
181    }
182}
183
184/// A block as it goes back into the conversation: a tool call gets its input read from the
185/// fragments; an input that is not JSON is kept as a string under `input` so that the model sees
186/// what it sent.
187fn finish(block: Block) -> Value {
188    let Block { mut value, input } = block;
189    if value.get("type").and_then(Value::as_str) == Some("tool_use")
190        && let Some(object) = value.as_object_mut()
191    {
192        let parsed = if input.trim().is_empty() {
193            Value::Object(Map::new())
194        } else {
195            serde_json::from_str(&input).unwrap_or(Value::String(input))
196        };
197        object.insert("input".to_owned(), parsed);
198    }
199    value
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    /// The events of a reply, written the way servers send them.
207    fn stream(events: &[Value]) -> String {
208        events
209            .iter()
210            .map(|event| format!("event: {}\ndata: {event}\n\n", event["type"].as_str().unwrap_or("")))
211            .collect()
212    }
213
214    fn collect(text: &str) -> (Result<Reply, ProviderError>, Vec<Delta>) {
215        let mut deltas = Vec::new();
216        let reply = read(text.as_bytes(), &mut |delta| deltas.push(delta), &|| false);
217        (reply, deltas)
218    }
219
220    #[test]
221    fn text_and_thinking_arrive_in_pieces_and_end_up_whole() {
222        let text = stream(&[
223            json!({"type": "message_start", "message": {"usage": {"input_tokens": 14}}}),
224            json!({"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": "", "signature": ""}}),
225            json!({"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "Short "}}),
226            json!({"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "answer."}}),
227            json!({"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": "sig"}}),
228            json!({"type": "content_block_stop", "index": 0}),
229            json!({"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}}),
230            json!({"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "Hey"}}),
231            json!({"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": " there"}}),
232            json!({"type": "content_block_stop", "index": 1}),
233            json!({"type": "ping"}),
234            json!({"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 9}}),
235            json!({"type": "message_stop"}),
236        ]);
237        let (reply, deltas) = collect(&text);
238        let reply = reply.expect("a reply");
239        assert_eq!(
240            deltas,
241            [
242                Delta::Thinking("Short ".into()),
243                Delta::Thinking("answer.".into()),
244                Delta::Text("Hey".into()),
245                Delta::Text(" there".into())
246            ]
247        );
248        assert_eq!(
249            reply.content,
250            [
251                json!({"type": "thinking", "thinking": "Short answer.", "signature": "sig"}),
252                json!({"type": "text", "text": "Hey there"})
253            ]
254        );
255        assert_eq!(reply.stop, Stop::EndTurn);
256        assert_eq!((reply.input_tokens, reply.output_tokens), (Some(14), Some(9)));
257    }
258
259    #[test]
260    fn a_tool_call_is_announced_by_name_and_its_input_read_from_fragments() {
261        let text = stream(&[
262            json!({"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "t1", "name": "read_file", "input": {}}}),
263            json!({"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"path\": \"src/"}}),
264            json!({"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "main.rs\"}"}}),
265            json!({"type": "content_block_stop", "index": 0}),
266            json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}}),
267            json!({"type": "message_stop"}),
268        ]);
269        let (reply, deltas) = collect(&text);
270        let reply = reply.expect("a reply");
271        assert_eq!(deltas, [Delta::ToolCall("read_file".into())]);
272        assert_eq!(
273            reply.content,
274            [json!({"type": "tool_use", "id": "t1", "name": "read_file", "input": {"path": "src/main.rs"}})]
275        );
276        assert_eq!(reply.stop, Stop::ToolUse);
277    }
278
279    #[test]
280    fn an_error_event_and_a_cut_stream_are_errors() {
281        let error = stream(&[json!({"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}})]);
282        assert_eq!(collect(&error).0, Err(ProviderError::Stopped { said: "Overloaded".into() }));
283        let cut = stream(&[
284            json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
285        ]);
286        assert!(matches!(collect(&cut).0, Err(ProviderError::Unreadable { .. })), "no message_stop, no reply");
287    }
288
289    #[test]
290    fn a_cancelled_reply_keeps_what_had_arrived() {
291        let text = stream(&[
292            json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
293            json!({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Half"}}),
294            json!({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": " more"}}),
295        ]);
296        let mut seen = 0;
297        let reply = read(text.as_bytes(), &mut |_| seen += 1, &|| true).expect("what arrived");
298        assert_eq!(reply.content, [json!({"type": "text", "text": ""})], "stopped after the first event");
299        assert_eq!(seen, 0);
300    }
301}