Skip to main content

llm_universal/
anthropic.rs

1use std::collections::BTreeMap;
2
3use async_trait::async_trait;
4use serde_json::{json, Value};
5
6use crate::http::{secret_header, Http};
7use crate::openai::{parse_arguments, usage};
8use crate::stream::{self, parse_data, Accumulator, ChatStream, StreamEvent};
9use crate::{
10    ApiError, ChatRequest, ChatResponse, ImageSource, LlmError, LlmProvider, Message, ModelInfo,
11    Part, Result, Role, StopReason, DEFAULT_MAX_TOKENS,
12};
13
14/// Value of the `anthropic-version` header.
15const API_VERSION: &str = "2023-06-01";
16/// `anthropic_version` body field required by Vertex AI.
17const VERTEX_API_VERSION: &str = "vertex-2023-10-16";
18
19/// Client for the Anthropic Messages API (`POST {base_url}/v1/messages`), or for Claude on
20/// Google Cloud Vertex AI via [`vertex`](Self::vertex).
21#[derive(Clone)]
22pub struct AnthropicProvider {
23    http: Http,
24    api_key: String,
25    base_url: String,
26    vertex: Option<Vertex>,
27}
28
29#[derive(Clone, Debug)]
30pub(crate) struct Vertex {
31    pub(crate) project: String,
32    pub(crate) region: String,
33}
34
35impl Vertex {
36    pub(crate) fn base_url(region: &str) -> String {
37        if region == "global" {
38            "https://aiplatform.googleapis.com/v1".to_string()
39        } else {
40            format!("https://{region}-aiplatform.googleapis.com/v1")
41        }
42    }
43
44    /// `{base}/projects/{project}/locations/{region}/publishers/{publisher}/models/{model}:{method}`
45    pub(crate) fn url(
46        &self,
47        provider: &str,
48        base_url: &str,
49        publisher: &str,
50        model: &str,
51        method: &str,
52    ) -> Result<String> {
53        let project = crate::checked_path_segment(provider, "project", &self.project, &[])?;
54        let region = crate::checked_path_segment(provider, "region", &self.region, &[])?;
55        let model = crate::checked_path_segment(provider, "model", model, &['@'])?;
56        Ok(format!(
57            "{base_url}/projects/{project}/locations/{region}/publishers/{publisher}/models/{model}:{method}"
58        ))
59    }
60}
61
62impl std::fmt::Debug for AnthropicProvider {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("AnthropicProvider")
65            .field("api_key", &"<redacted>")
66            .field("base_url", &self.base_url)
67            .field("vertex", &self.vertex)
68            .field("max_retries", &self.http.max_retries)
69            .finish()
70    }
71}
72
73impl AnthropicProvider {
74    pub fn new(api_key: impl Into<String>) -> Self {
75        Self {
76            http: Http::default(),
77            api_key: api_key.into(),
78            base_url: "https://api.anthropic.com".to_string(),
79            vertex: None,
80        }
81    }
82
83    /// Claude on Google Cloud Vertex AI. `access_token` is an OAuth token
84    /// (e.g. `gcloud auth print-access-token`); `region` is e.g. `global` or `us-east5`.
85    pub fn vertex(
86        project: impl Into<String>,
87        region: impl Into<String>,
88        access_token: impl Into<String>,
89    ) -> Self {
90        let region = region.into();
91        Self {
92            http: Http::default(),
93            api_key: access_token.into(),
94            base_url: Vertex::base_url(&region),
95            vertex: Some(Vertex {
96                project: project.into(),
97                region,
98            }),
99        }
100    }
101
102    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
103        self.base_url = base_url.into().trim_end_matches('/').to_string();
104        self
105    }
106
107    /// Uses your own `reqwest::Client`, for example to share a connection pool or set a proxy.
108    pub fn with_client(mut self, client: reqwest::Client) -> Self {
109        self.http.client = client;
110        self
111    }
112
113    /// How many times a failed request is retried (default [`DEFAULT_MAX_RETRIES`](crate::DEFAULT_MAX_RETRIES); 0 disables).
114    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
115        self.http.max_retries = max_retries;
116        self
117    }
118
119    fn build_body(req: &ChatRequest) -> Value {
120        // Anthropic has no `system` role in `messages`: system messages are merged into `system`.
121        let mut system_parts: Vec<String> = req.system.iter().cloned().collect();
122        let mut messages = Vec::new();
123        for message in &req.messages {
124            if message.role == Role::System {
125                system_parts.push(message.text_content());
126            } else {
127                messages.push(to_anthropic_message(message));
128            }
129        }
130
131        let mut body = json!({
132            "model": req.model,
133            "max_tokens": req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
134            "messages": messages,
135        });
136        if !system_parts.is_empty() {
137            body["system"] = json!(system_parts.join("\n\n"));
138        }
139        if !req.tools.is_empty() {
140            let tools: Vec<Value> = req
141                .tools
142                .iter()
143                .map(|t| {
144                    json!({
145                        "name": t.name,
146                        "description": t.description,
147                        "input_schema": t.parameters,
148                    })
149                })
150                .collect();
151            body["tools"] = json!(tools);
152        }
153        if let Some(temperature) = req.temperature {
154            body["temperature"] = json!(temperature);
155        }
156        let mut output_config = serde_json::Map::new();
157        if let Some(format) = &req.response_format {
158            output_config.insert(
159                "format".into(),
160                json!({ "type": "json_schema", "schema": format.schema }),
161            );
162        }
163        if let Some(effort) = req.reasoning {
164            // Adaptive thinking with readable summaries; depth is set by effort.
165            body["thinking"] = json!({ "type": "adaptive", "display": "summarized" });
166            output_config.insert("effort".into(), json!(effort.as_str()));
167        }
168        if !output_config.is_empty() {
169            body["output_config"] = Value::Object(output_config);
170        }
171        body
172    }
173
174    fn request(&self, url: String) -> Result<reqwest::RequestBuilder> {
175        let builder = self.http.client.post(url);
176        Ok(if self.vertex.is_some() {
177            builder.header(
178                reqwest::header::AUTHORIZATION,
179                secret_header("AnthropicProvider", &format!("Bearer {}", self.api_key))?,
180            )
181        } else {
182            builder
183                .header(
184                    "x-api-key",
185                    secret_header("AnthropicProvider", &self.api_key)?,
186                )
187                .header("anthropic-version", API_VERSION)
188        })
189    }
190
191    /// URL and body for a chat call, streaming or not.
192    fn chat_request(&self, req: &ChatRequest, stream: bool) -> Result<reqwest::RequestBuilder> {
193        // Anthropic allows tool names up to 128 characters.
194        crate::validate(
195            "AnthropicProvider",
196            &self.api_key,
197            true,
198            &self.base_url,
199            128,
200            req,
201        )?;
202        let mut body = Self::build_body(req);
203        if stream {
204            body["stream"] = json!(true);
205        }
206        let url = match &self.vertex {
207            Some(vertex) => {
208                // Vertex takes the model from the URL and the version from the body.
209                if let Some(obj) = body.as_object_mut() {
210                    obj.remove("model");
211                    obj.insert("anthropic_version".into(), json!(VERTEX_API_VERSION));
212                }
213                let method = if stream {
214                    "streamRawPredict"
215                } else {
216                    "rawPredict"
217                };
218                vertex.url(
219                    "AnthropicProvider",
220                    &self.base_url,
221                    "anthropic",
222                    &req.model,
223                    method,
224                )?
225            }
226            None => format!("{}/v1/messages", self.base_url),
227        };
228        Ok(self.request(url)?.json(&body))
229    }
230}
231
232fn to_anthropic_message(message: &Message) -> Value {
233    // Tool results are `tool_result` blocks inside a `user` turn.
234    let role = if message.role == Role::Assistant {
235        "assistant"
236    } else {
237        "user"
238    };
239    let content: Vec<Value> = message
240        .parts
241        .iter()
242        .filter_map(to_anthropic_block)
243        .collect();
244    json!({ "role": role, "content": content })
245}
246
247fn to_anthropic_block(part: &Part) -> Option<Value> {
248    Some(match part {
249        // The API rejects empty text blocks.
250        Part::Text { text } if text.is_empty() => return None,
251        Part::Text { text } => json!({ "type": "text", "text": text }),
252        Part::Image { source } => {
253            let source = match source {
254                ImageSource::Url { url } => json!({ "type": "url", "url": url }),
255                ImageSource::Base64 { media_type, data } => {
256                    json!({ "type": "base64", "media_type": media_type, "data": data })
257                }
258            };
259            json!({ "type": "image", "source": source })
260        }
261        // Thinking blocks go back exactly as received (they are signed).
262        Part::Reasoning { raw, .. } if raw.is_object() => raw.clone(),
263        Part::Reasoning { .. } => return None,
264        // Provider blocks this crate does not model (server tools, …) go back as received.
265        Part::Other { raw } if raw.is_object() => raw.clone(),
266        Part::Other { .. } => return None,
267        Part::ToolCall {
268            id,
269            name,
270            arguments,
271            ..
272        } => {
273            // `input` must be an object; arguments kept raw (invalid JSON) cannot be sent as one.
274            let input = if arguments.is_object() {
275                arguments.clone()
276            } else {
277                json!({})
278            };
279            json!({ "type": "tool_use", "id": id, "name": name, "input": input })
280        }
281        Part::ToolResult {
282            call_id,
283            content,
284            is_error,
285        } => json!({
286            "type": "tool_result",
287            "tool_use_id": call_id,
288            "content": content,
289            "is_error": is_error,
290        }),
291    })
292}
293
294fn parse_response(v: &Value) -> Result<ChatResponse> {
295    let blocks = v["content"]
296        .as_array()
297        .ok_or_else(|| LlmError::InvalidResponse("missing field `content`".into()))?;
298
299    let mut parts = Vec::new();
300    for block in blocks {
301        match block["type"].as_str() {
302            Some("text") => {
303                if let Some(text) = block["text"].as_str().filter(|t| !t.is_empty()) {
304                    parts.push(Part::text(text));
305                }
306            }
307            Some("tool_use") => parts.push(Part::ToolCall {
308                id: block["id"].as_str().unwrap_or_default().to_string(),
309                name: block["name"].as_str().unwrap_or_default().to_string(),
310                arguments: block["input"].clone(),
311                signature: None,
312            }),
313            Some("thinking") => parts.push(Part::Reasoning {
314                text: block["thinking"].as_str().unwrap_or_default().to_string(),
315                raw: block.clone(),
316            }),
317            Some("redacted_thinking") => parts.push(Part::Reasoning {
318                text: String::new(),
319                raw: block.clone(),
320            }),
321            // Other block types (server tools, …) are kept so they can be sent back, e.g. to
322            // continue a paused turn.
323            Some(_) if block.is_object() => parts.push(Part::Other { raw: block.clone() }),
324            _ => {}
325        }
326    }
327
328    let stop_reason = match v["stop_reason"].as_str() {
329        Some("end_turn" | "stop_sequence") => StopReason::EndTurn,
330        Some("tool_use") => StopReason::ToolUse,
331        Some("max_tokens" | "model_context_window_exceeded") => StopReason::MaxTokens,
332        Some("pause_turn") => StopReason::Paused,
333        Some("refusal") => StopReason::Refusal,
334        other => StopReason::Other(other.unwrap_or("unknown").to_string()),
335    };
336
337    Ok(ChatResponse {
338        model: v["model"].as_str().unwrap_or_default().to_string(),
339        message: Message::new(Role::Assistant, parts),
340        stop_reason,
341        usage: usage(&v["usage"], "input_tokens", "output_tokens"),
342    })
343}
344
345/// Rebuilds the non-streaming message from `message_start` / `content_block_*` /
346/// `message_delta` events.
347#[derive(Default)]
348struct AnthropicStream {
349    message: Value,
350    /// Content blocks by index; tool inputs are collected as JSON text first.
351    blocks: BTreeMap<u64, Value>,
352    tool_json: BTreeMap<u64, String>,
353    done: bool,
354}
355
356impl Accumulator for AnthropicStream {
357    fn on_event(&mut self, _event: &str, data: &str) -> Result<Vec<StreamEvent>> {
358        let v = parse_data(data)?;
359        let index = v["index"].as_u64().unwrap_or(0);
360        let mut out = Vec::new();
361        match v["type"].as_str() {
362            Some("message_start") => {
363                if !v["message"].is_object() {
364                    return Err(LlmError::InvalidResponse(
365                        "malformed message_start event".into(),
366                    ));
367                }
368                self.message = v["message"].clone();
369            }
370            Some("content_block_start") => {
371                let block = v["content_block"].clone();
372                if !block.is_object() {
373                    return Err(LlmError::InvalidResponse(
374                        "malformed content_block_start event".into(),
375                    ));
376                }
377                if block["type"] == "tool_use" {
378                    out.push(StreamEvent::ToolCallStart {
379                        id: block["id"].as_str().unwrap_or_default().to_string(),
380                        name: block["name"].as_str().unwrap_or_default().to_string(),
381                    });
382                    self.tool_json.insert(index, String::new());
383                }
384                self.blocks.insert(index, block);
385            }
386            Some("content_block_delta") => {
387                let delta = &v["delta"];
388                let Some(block) = self.blocks.get_mut(&index) else {
389                    return Ok(out);
390                };
391                match delta["type"].as_str() {
392                    Some("text_delta") => {
393                        let text = delta["text"].as_str().unwrap_or_default();
394                        append(block, "text", text);
395                        out.push(StreamEvent::TextDelta(text.to_string()));
396                    }
397                    Some("thinking_delta") => {
398                        let text = delta["thinking"].as_str().unwrap_or_default();
399                        append(block, "thinking", text);
400                        out.push(StreamEvent::ReasoningDelta(text.to_string()));
401                    }
402                    Some("signature_delta") => {
403                        append(
404                            block,
405                            "signature",
406                            delta["signature"].as_str().unwrap_or_default(),
407                        );
408                    }
409                    Some("input_json_delta") => {
410                        let json = delta["partial_json"].as_str().unwrap_or_default();
411                        self.tool_json.entry(index).or_default().push_str(json);
412                        out.push(StreamEvent::ToolCallDelta {
413                            id: block["id"].as_str().unwrap_or_default().to_string(),
414                            arguments: json.to_string(),
415                        });
416                    }
417                    _ => {}
418                }
419            }
420            Some("content_block_stop") => {
421                if let (Some(json), Some(block)) =
422                    (self.tool_json.remove(&index), self.blocks.get_mut(&index))
423                {
424                    block["input"] = parse_arguments(&json);
425                }
426            }
427            Some("message_delta") => {
428                if !self.message.is_object() {
429                    self.message = json!({});
430                }
431                if let Some(reason) = v["delta"]["stop_reason"].as_str() {
432                    self.message["stop_reason"] = json!(reason);
433                }
434                if let Some(tokens) = v["usage"]["output_tokens"].as_u64() {
435                    if !self.message["usage"].is_object() {
436                        self.message["usage"] = json!({});
437                    }
438                    self.message["usage"]["output_tokens"] = json!(tokens);
439                }
440            }
441            Some("message_stop") => self.done = true,
442            Some("error") => {
443                return Err(LlmError::Api(Box::new(ApiError::from_stream_error(
444                    &v["error"],
445                ))));
446            }
447            // `ping` and unknown events carry nothing to keep.
448            _ => {}
449        }
450        Ok(out)
451    }
452
453    fn finish(&mut self) -> Result<ChatResponse> {
454        if !self.done {
455            return Err(crate::stream::ended_early());
456        }
457        let mut message = std::mem::take(&mut self.message);
458        message["content"] = json!(std::mem::take(&mut self.blocks)
459            .into_values()
460            .collect::<Vec<_>>());
461        parse_response(&message)
462    }
463}
464
465/// Appends in place (no copy of the text so far). `block` is always an object here.
466fn append(block: &mut Value, field: &str, text: &str) {
467    match &mut block[field] {
468        Value::String(current) => current.push_str(text),
469        other => *other = json!(text),
470    }
471}
472
473#[async_trait]
474impl LlmProvider for AnthropicProvider {
475    async fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
476        let builder = self.chat_request(req, false)?;
477        parse_response(&self.http.json(builder).await?)
478    }
479
480    async fn chat_stream(&self, req: &ChatRequest) -> Result<ChatStream> {
481        let builder = self.chat_request(req, true)?;
482        let response = self.http.send(builder).await?;
483        Ok(stream::drive(response, Box::<AnthropicStream>::default()))
484    }
485
486    async fn list_models(&self) -> Result<Vec<ModelInfo>> {
487        if self.vertex.is_some() {
488            return Err(LlmError::InvalidArgument(
489                "AnthropicProvider: list_models is not available on Vertex AI".into(),
490            ));
491        }
492        crate::validate_endpoint("AnthropicProvider", &self.api_key, true, &self.base_url)?;
493        let builder = self
494            .http
495            .client
496            .get(format!("{}/v1/models?limit=1000", self.base_url))
497            .header(
498                "x-api-key",
499                secret_header("AnthropicProvider", &self.api_key)?,
500            )
501            .header("anthropic-version", API_VERSION);
502        let v = self.http.json(builder).await?;
503        Ok(v["data"]
504            .as_array()
505            .into_iter()
506            .flatten()
507            .filter_map(|m| {
508                Some(ModelInfo {
509                    id: m["id"].as_str()?.to_string(),
510                    display_name: m["display_name"].as_str().map(str::to_string),
511                })
512            })
513            .collect())
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::{ReasoningEffort, Tool};
521
522    #[test]
523    fn moves_system_and_maps_tool_results() {
524        let req = ChatRequest::new("claude-opus-5-5")
525            .system("sys")
526            .tool(Tool::new("f", "d", json!({"type": "object"})))
527            .message(Message::user("hi"))
528            .message(Message::tool_result("toolu_1", "42", false));
529        let body = AnthropicProvider::build_body(&req);
530        assert_eq!(body["system"], "sys");
531        assert_eq!(body["max_tokens"], DEFAULT_MAX_TOKENS);
532        assert_eq!(body["tools"][0]["input_schema"]["type"], "object");
533        assert_eq!(body["messages"][1]["role"], "user");
534        assert_eq!(body["messages"][1]["content"][0]["type"], "tool_result");
535        assert_eq!(body["messages"][1]["content"][0]["tool_use_id"], "toolu_1");
536    }
537
538    #[test]
539    fn builds_images_schema_and_thinking() {
540        let req = ChatRequest::new("claude-opus-5-5")
541            .json_schema("answer", json!({"type": "object"}))
542            .reasoning(ReasoningEffort::Low)
543            .message(Message::user_with_image(
544                "what?",
545                Part::image_url("https://example.com/a.png"),
546            ));
547        let body = AnthropicProvider::build_body(&req);
548        let image = &body["messages"][0]["content"][1];
549        assert_eq!(image["type"], "image");
550        assert_eq!(image["source"]["url"], "https://example.com/a.png");
551        assert_eq!(body["output_config"]["format"]["type"], "json_schema");
552        assert_eq!(body["output_config"]["effort"], "low");
553        assert_eq!(body["thinking"]["type"], "adaptive");
554    }
555
556    #[test]
557    fn thinking_blocks_round_trip_unchanged() {
558        let v = json!({
559            "model": "claude-opus-5-5",
560            "content": [
561                { "type": "thinking", "thinking": "plan", "signature": "sig123" },
562                { "type": "tool_use", "id": "toolu_1", "name": "f", "input": { "x": 1 } }
563            ],
564            "stop_reason": "tool_use",
565            "usage": { "input_tokens": 3, "output_tokens": 9 }
566        });
567        let resp = parse_response(&v).unwrap();
568        assert_eq!(resp.reasoning(), "plan");
569        let sent = to_anthropic_message(&resp.message);
570        assert_eq!(sent["content"][0], v["content"][0]);
571        assert_eq!(sent["content"][1]["type"], "tool_use");
572    }
573
574    #[test]
575    fn stream_rebuilds_message() {
576        let mut acc = AnthropicStream::default();
577        let events = [
578            r#"{"type":"message_start","message":{"model":"claude-opus-5-5","content":[],"usage":{"input_tokens":5,"output_tokens":1}}}"#,
579            r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#,
580            r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}"#,
581            r#"{"type":"content_block_stop","index":0}"#,
582            r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"f","input":{}}}"#,
583            r#"{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"x\":"}}"#,
584            r#"{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"1}"}}"#,
585            r#"{"type":"content_block_stop","index":1}"#,
586            r#"{"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":12}}"#,
587            r#"{"type":"message_stop"}"#,
588        ];
589        for e in events {
590            acc.on_event("", e).unwrap();
591        }
592        let resp = acc.finish().unwrap();
593        assert_eq!(resp.text(), "Hi");
594        assert_eq!(resp.tool_calls()[0].arguments["x"], 1);
595        assert_eq!(resp.stop_reason, StopReason::ToolUse);
596        assert_eq!(resp.usage.input_tokens, 5);
597        assert_eq!(resp.usage.output_tokens, 12);
598    }
599
600    #[test]
601    fn stream_error_event_maps_to_api_error() {
602        let mut acc = AnthropicStream::default();
603        let err = acc
604            .on_event(
605                "error",
606                r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#,
607            )
608            .unwrap_err();
609        assert_eq!(err.status(), Some(529));
610        assert!(err.is_retryable());
611    }
612
613    #[test]
614    fn vertex_url_and_body() {
615        let p = AnthropicProvider::vertex("my-proj", "us-east5", "token");
616        let req = ChatRequest::new("claude-opus-5-5").message(Message::user("hi"));
617        let built = p.chat_request(&req, false).unwrap().build().unwrap();
618        assert_eq!(
619            built.url().as_str(),
620            "https://us-east5-aiplatform.googleapis.com/v1/projects/my-proj/locations/us-east5/publishers/anthropic/models/claude-opus-5-5:rawPredict"
621        );
622        let body: Value =
623            serde_json::from_slice(built.body().unwrap().as_bytes().unwrap()).unwrap();
624        assert!(body.get("model").is_none());
625        assert_eq!(body["anthropic_version"], VERTEX_API_VERSION);
626        assert!(built.headers()["authorization"].is_sensitive());
627
628        let bad = AnthropicProvider::vertex("../x", "us-east5", "token");
629        assert!(bad.chat_request(&req, false).is_err());
630    }
631
632    #[test]
633    fn debug_redacts_api_key() {
634        let debug = format!("{:?}", AnthropicProvider::new("sk-ant-secret"));
635        assert!(!debug.contains("sk-ant-secret"));
636    }
637}
638
639#[cfg(test)]
640mod regression_tests {
641    use super::*;
642
643    #[test]
644    fn server_tool_blocks_are_kept_for_a_paused_turn() {
645        let v = json!({
646            "content": [{ "type": "server_tool_use", "id": "srv_1", "name": "web_search", "input": {} }],
647            "stop_reason": "pause_turn"
648        });
649        let resp = parse_response(&v).unwrap();
650        assert_eq!(resp.stop_reason, StopReason::Paused);
651        assert_eq!(
652            to_anthropic_message(&resp.message)["content"][0],
653            v["content"][0]
654        );
655    }
656
657    #[test]
658    fn raw_string_arguments_are_not_sent_as_input() {
659        let msg = Message::new(
660            Role::Assistant,
661            vec![Part::ToolCall {
662                id: "t".into(),
663                name: "f".into(),
664                arguments: json!("{\"x\":"),
665                signature: None,
666            }],
667        );
668        assert_eq!(to_anthropic_message(&msg)["content"][0]["input"], json!({}));
669    }
670
671    #[test]
672    fn malformed_events_do_not_panic() {
673        let mut acc = AnthropicStream::default();
674        assert!(acc
675            .on_event("", r#"{"type":"message_start","message":5}"#)
676            .is_err());
677        assert!(acc
678            .on_event(
679                "",
680                r#"{"type":"content_block_start","index":0,"content_block":"x"}"#
681            )
682            .is_err());
683        let mut acc = AnthropicStream::default();
684        acc.on_event("", r#"{"type":"message_start","message":{"usage":1}}"#)
685            .unwrap();
686        acc.on_event(
687            "",
688            r#"{"type":"message_delta","delta":{},"usage":{"output_tokens":3}}"#,
689        )
690        .unwrap();
691    }
692}