1use std::time::Duration;
7
8use async_trait::async_trait;
9use futures_util::StreamExt;
10use reqwest::Client;
11use serde::Deserialize;
12use serde_json::Value;
13
14use super::{
15 ChatProvider, Completion, RetryPolicy, StreamChunk, StreamSender, TokenUsage, ToolCall,
16 ToolSpec,
17};
18
19const TOOL_SEARCH_BETA_HEADER: &str = "advanced-tool-use-2025-11-20";
22
23use crate::error::{Error, Result};
24use crate::message::{Message, Role};
25
26#[derive(Debug, Clone)]
27pub struct AnthropicProvider {
28 base_url: String,
29 api_key: String,
30 model: String,
31 client: Client,
32 temperature: f64,
33 max_tokens: u32,
34 retry: RetryPolicy,
35 #[allow(
40 dead_code,
41 reason = "API parity placeholder; used once Anthropic adds deferred search"
42 )]
43 max_search_rounds: usize,
44}
45
46impl AnthropicProvider {
47 pub fn new(
48 base_url: impl Into<String>,
49 api_key: impl Into<String>,
50 model: impl Into<String>,
51 ) -> Result<Self> {
52 let client = Client::builder()
53 .timeout(Duration::from_secs(180))
54 .build()
55 .map_err(|e| Error::Config {
56 message: format!("failed to build HTTP client: {e}"),
57 })?;
58 Ok(Self {
59 base_url: base_url.into().trim_end_matches('/').to_string(),
60 api_key: api_key.into(),
61 model: model.into(),
62 client,
63 temperature: 0.2,
64 max_tokens: crate::llm::DEFAULT_MAX_TOKENS,
71 retry: RetryPolicy::default(),
72 max_search_rounds: 3,
73 })
74 }
75
76 fn make_err(&self, ctx: impl Into<String>) -> Error {
78 Error::Llm {
79 provider: self.model.clone(),
80 message: ctx.into(),
81 }
82 }
83
84 pub fn with_temperature(mut self, t: f64) -> Self {
85 self.temperature = t;
86 self
87 }
88
89 pub fn with_max_tokens(mut self, n: u32) -> Self {
90 self.max_tokens = n;
91 self
92 }
93
94 pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
95 self.retry = policy;
96 self
97 }
98
99 pub fn with_max_search_rounds(mut self, n: usize) -> Self {
102 self.max_search_rounds = n;
103 self
104 }
105
106 async fn post_with_retry(&self, url: &str, body: &Value) -> Result<String> {
108 let mut attempt = 0;
109 loop {
110 tracing::debug!(target: "recursive::llm", request = %body, "POST {}", url);
111 let result = self
112 .client
113 .post(url)
114 .header("x-api-key", &self.api_key)
115 .header("anthropic-version", "2023-06-01")
116 .header("anthropic-beta", TOOL_SEARCH_BETA_HEADER)
117 .header("content-type", "application/json")
118 .json(body)
119 .send()
120 .await;
121 match result {
122 Ok(resp) => {
123 let status = resp.status();
124 if status.is_success() {
125 return resp.text().await.map_err(Error::from);
126 }
127 let text = resp.text().await?;
128 if let Some(backoff) =
129 self.retry
130 .backoff_for(attempt, Some(status.as_u16()), false)
131 {
132 tracing::warn!(
133 target: "recursive::llm",
134 attempt,
135 backoff_ms = backoff.as_millis(),
136 status = status.as_u16(),
137 "transient HTTP error, retrying"
138 );
139 tokio::time::sleep(backoff).await;
140 attempt += 1;
141 continue;
142 }
143 return Err(self.make_err(format!("HTTP {}: {}", status, text)));
144 }
145 Err(e) => {
146 if let Some(backoff) = self.retry.backoff_for(attempt, None, true) {
147 tracing::warn!(
148 target: "recursive::llm",
149 attempt,
150 backoff_ms = backoff.as_millis(),
151 error = %e,
152 "network error, retrying"
153 );
154 tokio::time::sleep(backoff).await;
155 attempt += 1;
156 continue;
157 }
158 return Err(self.make_err(format!("request failed: {e}")));
159 }
160 }
161 }
162 }
163}
164
165#[async_trait]
166impl ChatProvider for AnthropicProvider {
167 fn supports_deferred_tools(&self) -> bool {
177 if std::env::var("RECURSIVE_DEFERRED_TOOLS")
178 .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
179 .unwrap_or(false)
180 {
181 return true;
182 }
183 self.base_url.contains("api.anthropic.com")
184 }
185
186 async fn complete(&self, messages: &[Message], tools: &[ToolSpec]) -> Result<Completion> {
187 let (system, messages) = extract_system_message(messages);
188 let messages = filter_leading_assistant(&messages);
189 let body = build_request(
190 &self.model,
191 self.temperature,
192 self.max_tokens,
193 system.as_deref(),
194 &messages,
195 tools,
196 );
197 let url = format!("{}/v1/messages", self.base_url);
198 let text = self.post_with_retry(&url, &body).await?;
199 let parsed: AnthropicResponse = serde_json::from_str(&text)
200 .map_err(|e| self.make_err(format!("failed to parse response: {e}; body: {text}")))?;
201 Ok(parse_completion(parsed))
202 }
203
204 async fn stream(
205 &self,
206 messages: &[Message],
207 tools: &[ToolSpec],
208 stream_tx: Option<StreamSender>,
209 cancel_token: Option<tokio_util::sync::CancellationToken>,
210 ) -> Result<Completion> {
211 self.stream_inner(messages, tools, stream_tx, cancel_token)
212 .await
213 }
214}
215
216impl AnthropicProvider {
217 async fn stream_inner(
221 &self,
222 messages: &[Message],
223 tools: &[ToolSpec],
224 stream_tx: Option<StreamSender>,
225 cancel_token: Option<tokio_util::sync::CancellationToken>,
226 ) -> Result<Completion> {
227 let (system, messages) = extract_system_message(messages);
228 let messages = filter_leading_assistant(&messages);
229
230 let mut body = build_request(
231 &self.model,
232 self.temperature,
233 self.max_tokens,
234 system.as_deref(),
235 &messages,
236 tools,
237 );
238 body["stream"] = Value::Bool(true);
239
240 let url = format!("{}/v1/messages", self.base_url);
241
242 let mut attempt = 0;
243 loop {
244 tracing::debug!(target: "recursive::llm", request = %body, "POST {} (stream)", url);
245 let result = self
246 .client
247 .post(&url)
248 .header("x-api-key", &self.api_key)
249 .header("anthropic-version", "2023-06-01")
250 .header("anthropic-beta", TOOL_SEARCH_BETA_HEADER)
251 .header("content-type", "application/json")
252 .json(&body)
253 .send()
254 .await;
255
256 match result {
257 Ok(resp) => {
258 let status = resp.status();
259 if status.is_success() {
260 return self
261 .parse_sse_stream(resp, stream_tx.clone(), cancel_token.clone())
262 .await;
263 }
264
265 let text = resp.text().await?;
267 tracing::debug!(target: "recursive::llm", body = %text, "error response (stream)");
268
269 if let Some(backoff) =
270 self.retry
271 .backoff_for(attempt, Some(status.as_u16()), false)
272 {
273 tracing::warn!(
274 target: "recursive::llm",
275 attempt,
276 backoff_ms = backoff.as_millis(),
277 status = status.as_u16(),
278 "transient HTTP error, retrying (stream)"
279 );
280 tokio::time::sleep(backoff).await;
281 attempt += 1;
282 continue;
283 }
284
285 return Err(self.make_err(format!("HTTP {}: {}", status, text)));
286 }
287 Err(e) => {
288 if let Some(backoff) = self.retry.backoff_for(attempt, None, true) {
289 tracing::warn!(
290 target: "recursive::llm",
291 attempt,
292 backoff_ms = backoff.as_millis(),
293 error = %e,
294 "network error, retrying (stream)"
295 );
296 tokio::time::sleep(backoff).await;
297 attempt += 1;
298 continue;
299 }
300
301 return Err(self.make_err(format!("request failed: {e}")));
302 }
303 }
304 }
305 }
306
307 async fn parse_sse_stream(
321 &self,
322 resp: reqwest::Response,
323 stream_tx: Option<StreamSender>,
324 cancel_token: Option<tokio_util::sync::CancellationToken>,
325 ) -> Result<Completion> {
326 let mut acc = SseAccum::default();
334 let mut byte_stream = resp.bytes_stream();
335 let mut incomplete: Vec<u8> = Vec::new();
336 let mut line_buf = String::new();
337
338 loop {
343 let chunk_result = if let Some(ct) = cancel_token.as_ref() {
344 tokio::select! {
345 biased;
346 _ = ct.cancelled() => {
347 let have_partial = !acc.content.is_empty()
356 || !acc.reasoning_content.is_empty()
357 || !acc.tool_calls.is_empty();
358 if have_partial {
359 tracing::warn!(
360 target: "recursive::llm",
361 content_len = acc.content.len(),
362 "SSE stream cancelled; returning partial completion"
363 );
364 acc.tool_calls.clear();
366 return Ok(Self::build_completion(
367 acc,
368 Some("interrupted".to_string()),
369 ));
370 }
371 return Err(Error::Cancelled);
372 }
373 next = byte_stream.next() => next,
374 }
375 } else {
376 byte_stream.next().await
377 };
378 let bytes = match chunk_result {
379 Some(Ok(c)) => c,
380 Some(Err(e)) => {
381 let have_partial = !acc.content.is_empty()
388 || !acc.reasoning_content.is_empty()
389 || !acc.tool_calls.is_empty();
390 if have_partial {
391 tracing::warn!(
392 target: "recursive::llm",
393 error = %e,
394 content_len = acc.content.len(),
395 "SSE stream interrupted; returning partial completion"
396 );
397 acc.tool_calls.clear();
399 return Ok(Self::build_completion(acc, Some("interrupted".to_string())));
400 }
401 return Err(self.make_err(format!("SSE stream read error: {e}")));
402 }
403 None => break,
404 };
405 let combined: Vec<u8> = if incomplete.is_empty() {
406 bytes.to_vec()
407 } else {
408 let mut v = std::mem::take(&mut incomplete);
409 v.extend_from_slice(&bytes);
410 v
411 };
412 let valid_up_to = match std::str::from_utf8(&combined) {
413 Ok(_) => combined.len(),
414 Err(e) => e.valid_up_to(),
415 };
416 incomplete = combined[valid_up_to..].to_vec();
417 let text = unsafe { std::str::from_utf8_unchecked(&combined[..valid_up_to]) };
419 for ch in text.chars() {
420 if ch == '\n' {
421 let line = std::mem::take(&mut line_buf);
422 self.process_sse_line(&line, &mut acc, &stream_tx)?;
423 } else if ch != '\r' {
424 line_buf.push(ch);
425 }
426 }
427 }
428 Self::warn_incomplete_utf8_tail(&incomplete);
429 if !line_buf.is_empty() {
430 self.process_sse_line(&line_buf, &mut acc, &stream_tx)?;
431 }
432
433 let finish_reason = std::mem::take(&mut acc.finish_reason);
436 Ok(Self::build_completion(acc, finish_reason))
437 }
438
439 #[cfg_attr(test, mutants::skip)]
445 fn warn_incomplete_utf8_tail(incomplete: &[u8]) {
446 if !incomplete.is_empty() {
447 tracing::warn!(
448 target: "recursive::llm",
449 bytes = incomplete.len(),
450 "SSE stream ended with incomplete UTF-8 sequence; discarding tail bytes"
451 );
452 }
453 }
454
455 fn build_completion(acc: SseAccum, finish_reason: Option<String>) -> Completion {
461 let usage = if acc.input_tokens.is_some() || acc.output_tokens.is_some() {
463 let prompt = acc.input_tokens.unwrap_or(0);
464 let completion = acc.output_tokens.unwrap_or(0);
465 Some(TokenUsage {
466 reasoning_tokens: 0,
467 prompt_tokens: prompt,
468 completion_tokens: completion,
469 total_tokens: prompt.saturating_add(completion),
470 cache_hit_tokens: acc.cache_read.unwrap_or(0),
471 cache_miss_tokens: prompt.saturating_add(acc.cache_creation.unwrap_or(0)),
478 })
480 } else {
481 None
482 };
483
484 let final_tool_calls: Vec<ToolCall> = acc
486 .tool_calls
487 .into_iter()
488 .filter(|tc| !tc.id.is_empty())
489 .map(|tc| {
490 let args: Value = if tc.partial_json.trim().is_empty() {
491 Value::Object(Default::default())
492 } else {
493 serde_json::from_str(&tc.partial_json)
494 .unwrap_or_else(|_| Value::String(tc.partial_json.clone()))
495 };
496 ToolCall {
497 id: tc.id,
498 name: tc.name,
499 arguments: args,
500 }
501 })
502 .collect();
503
504 Completion {
505 content: acc.content,
506 tool_calls: final_tool_calls,
507 finish_reason: finish_reason.map(|r| match r.as_str() {
508 "end_turn" => "stop".to_string(),
509 "max_tokens" => "length".to_string(),
510 "tool_use" => "tool_calls".to_string(),
511 other => other.to_string(),
512 }),
513 usage,
514 reasoning_content: if acc.reasoning_content.trim().is_empty() {
515 None
516 } else {
517 Some(acc.reasoning_content)
518 },
519 }
520 }
521
522 fn process_sse_line(
530 &self,
531 line: &str,
532 acc: &mut SseAccum,
533 stream_tx: &Option<StreamSender>,
534 ) -> Result<()> {
535 if let Some(event_name) = line.strip_prefix("event: ") {
536 acc.current_event = Some(event_name.to_string());
537 return Ok(());
538 }
539 let Some(data) = line.strip_prefix("data: ") else {
540 return Ok(());
541 };
542 let event_type = acc.current_event.as_deref().unwrap_or("unknown");
543
544 match event_type {
545 "message_start" => {
546 let parsed: Value = serde_json::from_str(data).map_err(|e| {
547 self.make_err(format!(
548 "SSE parse error (message_start): {e}; data: {data}"
549 ))
550 })?;
551 if let Some(msg) = parsed.get("message") {
552 if let Some(u) = msg.get("usage") {
553 acc.input_tokens = u
554 .get("input_tokens")
555 .and_then(|v| v.as_u64())
556 .map(|v| v as u32);
557 acc.cache_creation = u
558 .get("cache_creation_input_tokens")
559 .and_then(|v| v.as_u64())
560 .map(|v| v as u32);
561 acc.cache_read = u
562 .get("cache_read_input_tokens")
563 .and_then(|v| v.as_u64())
564 .map(|v| v as u32);
565 }
566 }
567 }
568 "content_block_start" => {
569 let parsed: Value = serde_json::from_str(data).map_err(|e| {
570 self.make_err(format!(
571 "SSE parse error (content_block_start): {e}; data: {data}"
572 ))
573 })?;
574 if let Some(block) = parsed.get("content_block") {
575 let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
576 let index = parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
577 match block_type {
578 "tool_use" => {
579 let id = block
580 .get("id")
581 .and_then(|v| v.as_str())
582 .unwrap_or("")
583 .to_string();
584 let name = block
585 .get("name")
586 .and_then(|v| v.as_str())
587 .unwrap_or("")
588 .to_string();
589 while acc.tool_calls.len() <= index {
590 acc.tool_calls.push(StreamToolCall::default());
591 }
592 acc.tool_calls[index].id = id;
593 acc.tool_calls[index].name = name;
594 }
595 "text" => {
596 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
597 if !text.is_empty() {
598 acc.content.push_str(text);
599 if let Some(tx) = stream_tx {
600 let _ = tx.send(StreamChunk::Text(text.to_string()));
601 }
602 }
603 }
604 }
605 "thinking" => {
609 if let Some(t) = block.get("thinking").and_then(|v| v.as_str()) {
610 if !t.is_empty() {
611 acc.reasoning_content.push_str(t);
612 if let Some(tx) = stream_tx {
613 let _ = tx.send(StreamChunk::Reasoning(t.to_string()));
614 }
615 }
616 }
617 }
618 _ => {}
619 }
620 }
621 }
622 "content_block_delta" => {
623 let parsed: Value = serde_json::from_str(data).map_err(|e| {
624 self.make_err(format!(
625 "SSE parse error (content_block_delta): {e}; data: {data}"
626 ))
627 })?;
628 if let Some(delta) = parsed.get("delta") {
629 let delta_type = delta.get("type").and_then(|v| v.as_str()).unwrap_or("");
630 match delta_type {
631 "text_delta" => Self::handle_text_delta(acc, delta, stream_tx),
632 "input_json_delta" => {
633 Self::handle_input_json_delta(acc, delta, &parsed);
634 }
635 "thinking_delta" => Self::handle_thinking_delta(acc, delta, stream_tx),
640 _ => {}
641 }
642 }
643 }
644 "message_delta" => {
645 let parsed: Value = serde_json::from_str(data).map_err(|e| {
646 self.make_err(format!(
647 "SSE parse error (message_delta): {e}; data: {data}"
648 ))
649 })?;
650 if let Some(delta) = parsed.get("delta") {
651 if let Some(reason) = delta.get("stop_reason").and_then(|v| v.as_str()) {
652 acc.finish_reason = Some(reason.to_string());
653 }
654 }
655 if let Some(u) = parsed.get("usage") {
656 acc.output_tokens = u
657 .get("output_tokens")
658 .and_then(|v| v.as_u64())
659 .map(|v| v as u32);
660 if let Some(v) = u.get("input_tokens").and_then(|v| v.as_u64()) {
667 acc.input_tokens = Some(v as u32);
668 }
669 if let Some(v) = u.get("cache_read_input_tokens").and_then(|v| v.as_u64()) {
670 acc.cache_read = Some(v as u32);
671 }
672 if let Some(v) = u
673 .get("cache_creation_input_tokens")
674 .and_then(|v| v.as_u64())
675 {
676 acc.cache_creation = Some(v as u32);
677 }
678 }
679 }
680 _ => {
685 tracing::debug!(target: "recursive::llm", event = %event_type, data = %data, "unhandled SSE event");
686 }
687 }
688
689 acc.current_event = None;
690 Ok(())
691 }
692
693 fn handle_text_delta(acc: &mut SseAccum, delta: &Value, stream_tx: &Option<StreamSender>) {
696 if let Some(text) = delta.get("text").and_then(|v| v.as_str()) {
697 if !text.is_empty() {
698 acc.content.push_str(text);
699 if let Some(tx) = stream_tx {
700 let _ = tx.send(StreamChunk::Text(text.to_string()));
701 }
702 }
703 }
704 }
705
706 fn handle_input_json_delta(acc: &mut SseAccum, delta: &Value, parsed: &Value) {
708 if let Some(partial) = delta.get("partial_json").and_then(|v| v.as_str()) {
709 let index = parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
710 while acc.tool_calls.len() <= index {
711 acc.tool_calls.push(StreamToolCall::default());
712 }
713 acc.tool_calls[index].partial_json.push_str(partial);
714 }
715 }
716
717 fn handle_thinking_delta(acc: &mut SseAccum, delta: &Value, stream_tx: &Option<StreamSender>) {
719 if let Some(t) = delta.get("thinking").and_then(|v| v.as_str()) {
720 if !t.is_empty() {
721 acc.reasoning_content.push_str(t);
722 if let Some(tx) = stream_tx {
723 let _ = tx.send(StreamChunk::Reasoning(t.to_string()));
724 }
725 }
726 }
727 }
728}
729
730#[derive(Default)]
733struct SseAccum {
734 content: String,
735 reasoning_content: String,
736 tool_calls: Vec<StreamToolCall>,
737 finish_reason: Option<String>,
738 input_tokens: Option<u32>,
739 output_tokens: Option<u32>,
740 cache_creation: Option<u32>,
741 cache_read: Option<u32>,
742 current_event: Option<String>,
745}
746
747fn extract_system_message(messages: &[Message]) -> (Option<String>, Vec<Message>) {
749 if messages.first().is_some_and(|m| m.role == Role::System) {
750 let system = messages[0].content.clone();
751 let rest = messages[1..].to_vec();
752 (Some(system), rest)
753 } else {
754 (None, messages.to_vec())
755 }
756}
757
758fn filter_leading_assistant(messages: &[Message]) -> Vec<Message> {
761 let mut result = messages.to_vec();
762 while result
763 .first()
764 .is_some_and(|m| m.role == Role::Assistant && m.tool_calls.is_empty())
765 {
766 result.remove(0);
768 }
769 result
770}
771
772fn build_request(
773 model: &str,
774 temperature: f64,
775 max_tokens: u32,
776 system: Option<&str>,
777 messages: &[Message],
778 tools: &[ToolSpec],
779) -> Value {
780 let mut req = serde_json::json!({
781 "model": model,
782 "max_tokens": max_tokens,
783 "temperature": temperature,
784 });
785
786 if let Some(sys) = system {
787 req["system"] = Value::String(sys.to_string());
788 }
789
790 let msgs: Vec<Value> = serialize_messages_anthropic(messages);
791 req["messages"] = Value::Array(msgs);
792
793 if !tools.is_empty() {
794 let tools_json: Vec<Value> = tools
795 .iter()
796 .map(|t| {
797 serde_json::json!({
798 "name": t.name,
799 "description": t.description,
800 "input_schema": t.parameters,
801 })
802 })
803 .collect();
804 req["tools"] = Value::Array(tools_json);
805 }
806
807 req
808}
809
810fn serialize_message(m: &Message) -> Value {
811 let role = match m.role {
812 Role::System => "system",
813 Role::User => "user",
814 Role::Assistant => "assistant",
815 Role::Tool => "user", };
817
818 if !m.tool_calls.is_empty() {
820 let tool_uses: Vec<Value> = m
821 .tool_calls
822 .iter()
823 .map(|c| {
824 serde_json::json!({
825 "type": "tool_use",
826 "id": c.id,
827 "name": c.name,
828 "input": c.arguments,
829 })
830 })
831 .collect();
832 serde_json::json!({
833 "role": role,
834 "content": tool_uses,
835 })
836 } else if let Some(id) = &m.tool_call_id {
837 serde_json::json!({
839 "role": role,
840 "content": [{
841 "type": "tool_result",
842 "tool_use_id": id,
843 "content": m.content,
844 }],
845 })
846 } else {
847 serde_json::json!({
849 "role": role,
850 "content": m.content,
851 })
852 }
853}
854
855fn serialize_messages_anthropic(messages: &[Message]) -> Vec<Value> {
868 let mut out: Vec<Value> = Vec::with_capacity(messages.len());
869 let mut i = 0;
870 while i < messages.len() {
871 let m = &messages[i];
872 if m.tool_call_id.is_some() || m.role == Role::Tool {
874 let mut blocks: Vec<Value> = Vec::new();
876 while i < messages.len() {
877 let tm = &messages[i];
878 if tm.tool_call_id.is_none() && tm.role != Role::Tool {
879 break;
880 }
881 if let Some(id) = &tm.tool_call_id {
882 let content_value = if tm.content.starts_with('[') {
886 if let Ok(names) = serde_json::from_str::<Vec<String>>(&tm.content) {
887 Value::Array(
888 names
889 .iter()
890 .map(|n| {
891 serde_json::json!({
892 "type": "tool_reference",
893 "tool_name": n,
894 })
895 })
896 .collect(),
897 )
898 } else {
899 Value::String(tm.content.clone())
900 }
901 } else {
902 Value::String(tm.content.clone())
903 };
904 blocks.push(serde_json::json!({
905 "type": "tool_result",
906 "tool_use_id": id,
907 "content": content_value,
908 }));
909 }
910 i += 1;
911 }
912 if !blocks.is_empty() {
913 out.push(serde_json::json!({
914 "role": "user",
915 "content": blocks,
916 }));
917 }
918 } else {
919 out.push(serialize_message(m));
920 i += 1;
921 }
922 }
923 out
924}
925
926#[derive(Debug, Deserialize)]
927struct AnthropicResponse {
928 #[serde(default)]
929 content: Vec<ContentBlock>,
930 #[serde(default)]
931 usage: Option<AnthropicUsage>,
932 #[serde(default)]
933 stop_reason: Option<String>,
934}
935
936#[derive(Debug, Deserialize)]
937#[serde(tag = "type")]
938enum ContentBlock {
939 #[serde(rename = "text")]
940 Text {
941 #[serde(default)]
942 text: String,
943 },
944 #[serde(rename = "tool_use")]
945 ToolUse {
946 id: String,
947 name: String,
948 #[serde(default)]
949 input: Value,
950 },
951 #[serde(rename = "thinking")]
956 Thinking {
957 #[serde(default)]
958 thinking: String,
959 },
960 #[serde(other)]
961 Unknown,
962}
963
964#[derive(Debug, Deserialize)]
965struct AnthropicUsage {
966 #[serde(default)]
967 input_tokens: Option<u32>,
968 #[serde(default)]
969 output_tokens: Option<u32>,
970 #[serde(default)]
971 cache_creation_input_tokens: Option<u32>,
972 #[serde(default)]
973 cache_read_input_tokens: Option<u32>,
974}
975
976impl AnthropicUsage {
977 fn to_token_usage(&self) -> TokenUsage {
978 let prompt = self.input_tokens.unwrap_or(0);
979 TokenUsage {
980 reasoning_tokens: 0,
981 prompt_tokens: prompt,
982 completion_tokens: self.output_tokens.unwrap_or(0),
983 total_tokens: prompt.saturating_add(self.output_tokens.unwrap_or(0)),
984 cache_hit_tokens: self.cache_read_input_tokens.unwrap_or(0),
985 cache_miss_tokens: prompt.saturating_add(self.cache_creation_input_tokens.unwrap_or(0)),
989 }
993 }
994}
995
996#[derive(Default)]
998struct StreamToolCall {
999 id: String,
1000 name: String,
1001 partial_json: String,
1002}
1003
1004fn parse_completion(response: AnthropicResponse) -> Completion {
1005 let mut content = String::new();
1006 let mut reasoning_content = String::new();
1007 let mut tool_calls = Vec::new();
1008
1009 for block in response.content {
1010 match block {
1011 ContentBlock::Text { text } => {
1012 content.push_str(&text);
1013 }
1014 ContentBlock::Thinking { thinking } => {
1015 reasoning_content.push_str(&thinking);
1016 }
1017 ContentBlock::ToolUse { id, name, input } => {
1018 tool_calls.push(ToolCall {
1019 id,
1020 name,
1021 arguments: input,
1022 });
1023 }
1024 ContentBlock::Unknown => {}
1025 }
1026 }
1027
1028 let finish_reason = response.stop_reason.map(|r| match r.as_str() {
1029 "end_turn" => "stop".to_string(),
1030 "max_tokens" => "length".to_string(),
1031 "tool_use" => "tool_calls".to_string(),
1032 other => other.to_string(),
1033 });
1034
1035 Completion {
1036 content,
1037 tool_calls,
1038 finish_reason,
1039 usage: response.usage.map(|u| u.to_token_usage()),
1040 reasoning_content: if reasoning_content.trim().is_empty() {
1041 None
1042 } else {
1043 Some(reasoning_content)
1044 },
1045 }
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050 use super::*;
1051 use crate::tools::TOOL_SEARCH_TOOL_NAME;
1052 use serde_json::json;
1053
1054 #[test]
1055 fn extract_system_message_when_present() {
1056 let msgs = vec![
1057 Message::system("You are helpful.".to_string()),
1058 Message::user("Hello".to_string()),
1059 ];
1060 let (system, rest) = extract_system_message(&msgs);
1061 assert_eq!(system, Some("You are helpful.".to_string()));
1062 assert_eq!(rest.len(), 1);
1063 assert_eq!(rest[0].role, Role::User);
1064 }
1065
1066 #[test]
1067 fn extract_system_message_when_not_present() {
1068 let msgs = vec![Message::user("Hello".to_string())];
1069 let (system, rest) = extract_system_message(&msgs);
1070 assert_eq!(system, None);
1071 assert_eq!(rest.len(), 1);
1072 }
1073
1074 #[test]
1075 fn filter_leading_assistant_removes_empty_assistant() {
1076 let msgs = vec![
1077 Message::assistant("Hi".to_string()),
1078 Message::user("Hello".to_string()),
1079 ];
1080 let filtered = filter_leading_assistant(&msgs);
1081 assert_eq!(filtered.len(), 1);
1082 assert_eq!(filtered[0].role, Role::User);
1083 }
1084
1085 #[test]
1086 fn filter_leading_assistant_keeps_assistant_with_tool_calls() {
1087 let msgs = vec![
1088 Message::assistant_with_tool_calls(
1089 "I'll use a tool".to_string(),
1090 vec![ToolCall {
1091 id: "call_1".to_string(),
1092 name: "test".to_string(),
1093 arguments: Value::Object(Default::default()),
1094 }],
1095 ),
1096 Message::user("Hello".to_string()),
1097 ];
1098 let filtered = filter_leading_assistant(&msgs);
1099 assert_eq!(filtered.len(), 2);
1101 }
1102
1103 #[test]
1104 fn parses_text_response() {
1105 let raw = r#"{
1106 "type": "message",
1107 "content": [{"type": "text", "text": "Hello"}],
1108 "stop_reason": "end_turn",
1109 "usage": {"input_tokens": 10, "output_tokens": 5}
1110 }"#;
1111 let parsed: AnthropicResponse = serde_json::from_str(raw).unwrap();
1112 let c = parse_completion(parsed);
1113 assert_eq!(c.content, "Hello");
1114 assert!(c.tool_calls.is_empty());
1115 assert_eq!(c.finish_reason, Some("stop".to_string()));
1116 assert!(c.usage.is_some());
1117 }
1118
1119 #[test]
1120 fn parses_tool_use_response() {
1121 let raw = r#"{
1122 "type": "message",
1123 "content": [
1124 {"type": "tool_use", "id": "call_1", "name": "Read", "input": {"path": "src/lib.rs"}}
1125 ],
1126 "stop_reason": "tool_use",
1127 "usage": {"input_tokens": 100, "output_tokens": 50}
1128 }"#;
1129 let parsed: AnthropicResponse = serde_json::from_str(raw).unwrap();
1130 let c = parse_completion(parsed);
1131 assert!(c.content.is_empty());
1132 assert_eq!(c.tool_calls.len(), 1);
1133 assert_eq!(c.tool_calls[0].id, "call_1");
1134 assert_eq!(c.tool_calls[0].name, "Read");
1135 assert_eq!(c.tool_calls[0].arguments["path"], "src/lib.rs");
1136 }
1137
1138 #[test]
1139 fn parses_mixed_content_response() {
1140 let raw = r#"{
1141 "type": "message",
1142 "content": [
1143 {"type": "text", "text": "I'll read that file. "},
1144 {"type": "tool_use", "id": "call_1", "name": "Read", "input": {"path": "src/lib.rs"}}
1145 ],
1146 "stop_reason": "tool_use",
1147 "usage": {"input_tokens": 50, "output_tokens": 25}
1148 }"#;
1149 let parsed: AnthropicResponse = serde_json::from_str(raw).unwrap();
1150 let c = parse_completion(parsed);
1151 assert_eq!(c.content, "I'll read that file. ");
1152 assert_eq!(c.tool_calls.len(), 1);
1153 }
1154
1155 #[test]
1156 fn parses_thinking_block_into_reasoning_content() {
1157 let raw = r#"{
1161 "type": "message",
1162 "content": [
1163 {"type": "thinking", "thinking": "Let me reason about this.", "signature": "abc"},
1164 {"type": "text", "text": "The answer is 42."}
1165 ],
1166 "stop_reason": "end_turn",
1167 "usage": {"input_tokens": 10, "output_tokens": 5}
1168 }"#;
1169 let parsed: AnthropicResponse = serde_json::from_str(raw).unwrap();
1170 let c = parse_completion(parsed);
1171 assert_eq!(c.content, "The answer is 42.");
1172 assert_eq!(
1173 c.reasoning_content.as_deref(),
1174 Some("Let me reason about this.")
1175 );
1176 }
1177
1178 #[test]
1179 fn parses_usage_correctly() {
1180 let raw = r#"{
1181 "type": "message",
1182 "content": [{"type": "text", "text": "hi"}],
1183 "stop_reason": "end_turn",
1184 "usage": {
1185 "input_tokens": 100,
1186 "output_tokens": 50,
1187 "cache_creation_input_tokens": 20,
1188 "cache_read_input_tokens": 30
1189 }
1190 }"#;
1191 let parsed: AnthropicResponse = serde_json::from_str(raw).unwrap();
1192 let c = parse_completion(parsed);
1193 let u = c.usage.unwrap();
1194 assert_eq!(u.prompt_tokens, 100);
1195 assert_eq!(u.completion_tokens, 50);
1196 assert_eq!(u.cache_hit_tokens, 30);
1198 assert_eq!(u.cache_miss_tokens, 120);
1201 assert_eq!(
1202 u.cache_hit_tokens + u.cache_miss_tokens,
1203 100 + 30 + 20,
1204 "hit + miss must equal total input tokens"
1205 );
1206 }
1207
1208 #[test]
1209 fn serializes_tool_result_message() {
1210 let msg = Message::tool_result("call_123".to_string(), "result content".to_string());
1211 let v = serialize_message(&msg);
1212 assert_eq!(v["role"], "user");
1213 let content = &v["content"][0];
1214 assert_eq!(content["type"], "tool_result");
1215 assert_eq!(content["tool_use_id"], "call_123");
1216 assert_eq!(content["content"], "result content");
1217 }
1218
1219 #[test]
1220 fn serializes_assistant_with_tool_calls() {
1221 let msg = Message::assistant_with_tool_calls(
1222 "I'll use a tool".to_string(),
1223 vec![ToolCall {
1224 id: "abc".to_string(),
1225 name: "Write".to_string(),
1226 arguments: serde_json::json!({"path": "a", "contents": "b"}),
1227 }],
1228 );
1229 let v = serialize_message(&msg);
1230 assert_eq!(v["role"], "assistant");
1231 let content = &v["content"][0];
1232 assert_eq!(content["type"], "tool_use");
1233 assert_eq!(content["name"], "Write");
1234 }
1235
1236 #[test]
1237 fn builds_request_with_system() {
1238 let req = build_request(
1239 "claude-3",
1240 0.2,
1241 4096,
1242 Some("You are helpful."),
1243 &[Message::user("Hi".to_string())],
1244 &[],
1245 );
1246 assert_eq!(req["system"], "You are helpful.");
1247 assert_eq!(req["model"], "claude-3");
1248 }
1249
1250 #[test]
1251 fn builds_request_without_system() {
1252 let req = build_request(
1253 "claude-3",
1254 0.2,
1255 4096,
1256 None,
1257 &[Message::user("Hi".to_string())],
1258 &[],
1259 );
1260 assert!(req.get("system").is_none());
1261 }
1262
1263 #[test]
1264 fn builds_request_with_tools() {
1265 let tools = vec![ToolSpec {
1266 name: "test".to_string(),
1267 description: "A test tool".to_string(),
1268 parameters: serde_json::json!({"type": "object"}),
1269 }];
1270 let req = build_request(
1271 "claude-3",
1272 0.2,
1273 4096,
1274 None,
1275 &[Message::user("Hi".to_string())],
1276 &tools,
1277 );
1278 assert!(req.get("tools").is_some());
1279 }
1280
1281 #[tokio::test]
1282 async fn test_a_mock_server_returns_canned_response() {
1283 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1285 let addr = listener.local_addr().unwrap();
1286
1287 let handle = std::thread::spawn(move || {
1288 let (mut stream, _) = listener.accept().unwrap();
1289 use std::io::{Read, Write};
1290 let mut buf = [0u8; 4096];
1291 let _ = stream.read(&mut buf);
1292
1293 let body = r#"{"type":"message","content":[{"type":"text","text":"Hello from Anthropic"}],"stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":5}}"#;
1294 let response = format!(
1295 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1296 body.len(),
1297 body
1298 );
1299 write!(stream, "{}", response).unwrap();
1300 stream.flush().unwrap();
1301 });
1302
1303 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1305
1306 let provider =
1307 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
1308 let result = provider
1309 .complete(&[Message::user("hi".to_string())], &[])
1310 .await;
1311
1312 let _ = handle.join();
1313
1314 let completion = result.expect("should succeed");
1315 assert!(completion.content.contains("Hello from Anthropic"));
1316 assert!(completion.usage.is_some());
1317 let u = completion.usage.unwrap();
1318 assert_eq!(u.prompt_tokens, 10);
1319 assert_eq!(u.completion_tokens, 5);
1320 }
1321
1322 #[tokio::test]
1323 async fn test_b_error_response_includes_model_name() {
1324 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1326 let addr = listener.local_addr().unwrap();
1327
1328 let handle = std::thread::spawn(move || {
1329 let (mut stream, _) = listener.accept().unwrap();
1330 use std::io::{Read, Write};
1331 let mut buf = [0u8; 4096];
1332 let _ = stream.read(&mut buf);
1333
1334 let body = r#"{"error":{"type":"authentication_error","message":"Invalid API Key"}}"#;
1335 let response = format!(
1336 "HTTP/1.1 401 Unauthorized\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1337 body.len(),
1338 body
1339 );
1340 write!(stream, "{}", response).unwrap();
1341 stream.flush().unwrap();
1342 });
1343
1344 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1345
1346 let provider =
1347 AnthropicProvider::new(format!("http://{addr}"), "sk-invalid", "claude-3-opus")
1348 .unwrap();
1349 let err = provider
1350 .complete(&[Message::user("hi".to_string())], &[])
1351 .await
1352 .unwrap_err();
1353
1354 let _ = handle.join();
1355
1356 let msg = err.to_string();
1357 assert!(
1358 msg.contains("claude-3-opus"),
1359 "error should contain model name: {msg}"
1360 );
1361 assert!(
1362 msg.contains("401"),
1363 "error should contain HTTP status: {msg}"
1364 );
1365 }
1366
1367 #[tokio::test]
1368 async fn test_c_tool_use_extracts_correct_tool_call() {
1369 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1371 let addr = listener.local_addr().unwrap();
1372
1373 let handle = std::thread::spawn(move || {
1374 let (mut stream, _) = listener.accept().unwrap();
1375 use std::io::{Read, Write};
1376 let mut buf = [0u8; 4096];
1377 let _ = stream.read(&mut buf);
1378
1379 let body = r#"{"type":"message","content":[{"type":"tool_use","id":"call_abc123","name":"Read","input":{"path":"src/lib.rs"}}],"stop_reason":"tool_use","usage":{"input_tokens":50,"output_tokens":30}}"#;
1380 let response = format!(
1381 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1382 body.len(),
1383 body
1384 );
1385 write!(stream, "{}", response).unwrap();
1386 stream.flush().unwrap();
1387 });
1388
1389 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1390
1391 let provider =
1392 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
1393 let result = provider
1394 .complete(&[Message::user("read the file".to_string())], &[])
1395 .await;
1396
1397 let _ = handle.join();
1398
1399 let completion = result.expect("should succeed");
1400 assert_eq!(completion.tool_calls.len(), 1);
1401 let tc = &completion.tool_calls[0];
1402 assert_eq!(tc.id, "call_abc123");
1403 assert_eq!(tc.name, "Read");
1404 assert_eq!(tc.arguments["path"], "src/lib.rs");
1405 }
1406
1407 #[tokio::test]
1408 async fn test_d_stream_request_includes_stream_true() {
1409 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1412 let addr = listener.local_addr().unwrap();
1413 let captured = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
1414 let captured_clone = captured.clone();
1415
1416 std::thread::spawn(move || {
1417 let (mut stream, _) = listener.accept().unwrap();
1418 use std::io::{Read, Write};
1419 let mut buf = [0u8; 8192];
1420 let n = stream.read(&mut buf).unwrap();
1421 let request = String::from_utf8_lossy(&buf[..n]).to_string();
1422 if let Some(body_start) = request.find("\r\n\r\n") {
1423 let body = request[body_start + 4..].trim().to_string();
1424 *captured_clone.lock().unwrap() = body.clone();
1425 }
1426 let body = "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
1428 write!(
1429 stream,
1430 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1431 body.len(),
1432 body,
1433 ).unwrap();
1434 stream.flush().unwrap();
1435 });
1436
1437 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1438
1439 let provider =
1440 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
1441 let _ = provider
1442 .stream(&[Message::user("hi".to_string())], &[], None, None)
1443 .await;
1444
1445 let body_str = captured.lock().unwrap().clone();
1446 let body: serde_json::Value = serde_json::from_str(&body_str).unwrap();
1447 assert_eq!(body["stream"], true);
1448 }
1449
1450 #[tokio::test]
1451 async fn test_e_stream_text_deltas_accumulate() {
1452 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1455 let addr = listener.local_addr().unwrap();
1456
1457 std::thread::spawn(move || {
1458 let (mut stream, _) = listener.accept().unwrap();
1459 use std::io::{Read, Write};
1460 let mut buf = [0u8; 4096];
1461 let _ = stream.read(&mut buf);
1462 let body = "\
1463event: message_start
1464data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}
1465
1466event: content_block_start
1467data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"Hello\"}}
1468
1469event: content_block_delta
1470data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" \"}}
1471
1472event: content_block_delta
1473data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"World\"}}
1474
1475event: message_delta
1476data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}
1477
1478event: message_stop
1479data: {\"type\":\"message_stop\"}
1480
1481";
1482 write!(
1483 stream,
1484 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1485 body.len(),
1486 body,
1487 ).unwrap();
1488 stream.flush().unwrap();
1489 });
1490
1491 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1492
1493 let provider =
1494 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
1495 let completion = provider
1496 .stream(&[Message::user("hi".to_string())], &[], None, None)
1497 .await
1498 .unwrap();
1499 assert_eq!(completion.content, "Hello World");
1500 assert_eq!(completion.finish_reason.as_deref(), Some("stop"));
1501 assert!(completion.usage.is_some());
1502 let u = completion.usage.unwrap();
1503 assert_eq!(u.prompt_tokens, 10);
1504 assert_eq!(u.completion_tokens, 5);
1505 }
1506
1507 #[tokio::test]
1508 async fn parse_sse_stream_returns_error_on_malformed_chunk() {
1509 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1521 let addr = listener.local_addr().unwrap();
1522
1523 std::thread::spawn(move || {
1524 let (mut stream, _) = listener.accept().unwrap();
1525 use std::io::{Read, Write};
1526 let mut buf = [0u8; 4096];
1527 let _ = stream.read(&mut buf);
1528 let body = "\
1529event: message_start
1530data: {not valid json
1531
1532event: message_stop
1533data: {\"type\":\"message_stop\"}
1534
1535";
1536 write!(
1537 stream,
1538 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1539 body.len(),
1540 body,
1541 ).unwrap();
1542 stream.flush().unwrap();
1543 });
1544
1545 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1546
1547 let provider =
1548 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
1549 let result = tokio::time::timeout(
1552 std::time::Duration::from_secs(5),
1553 provider.stream(&[Message::user("hi".to_string())], &[], None, None),
1554 )
1555 .await
1556 .expect("SSE parser must not hang on malformed input");
1557
1558 let err = match result {
1559 Ok(_) => panic!("expected Err on malformed SSE chunk, got Ok"),
1560 Err(e) => e,
1561 };
1562 match err {
1563 Error::Llm { provider, message } => {
1564 assert!(
1565 message.contains("SSE parse error"),
1566 "error message should be diagnosable, got: {message}"
1567 );
1568 assert!(
1569 provider.contains("claude-3-sonnet"),
1570 "error should carry the provider/model name, got: {provider}"
1571 );
1572 }
1573 other => panic!("expected Error::Llm, got {other:?}"),
1574 }
1575 }
1576
1577 #[tokio::test]
1578 async fn parse_sse_stream_returns_partial_on_mid_stream_error() {
1579 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1586 let addr = listener.local_addr().unwrap();
1587
1588 std::thread::spawn(move || {
1589 let (mut stream, _) = listener.accept().unwrap();
1590 use std::io::{Read, Write};
1591 let mut buf = [0u8; 4096];
1592 let _ = stream.read(&mut buf);
1593 let body = "\
1594event: message_start
1595data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}
1596
1597event: content_block_start
1598data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"Hello\"}}
1599
1600event: content_block_delta
1601data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" partial\"}}
1602";
1603 write!(
1604 stream,
1605 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1606 9999,
1607 body,
1608 ).unwrap();
1609 stream.flush().unwrap();
1610 });
1613
1614 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1615
1616 let provider =
1617 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
1618 let completion = provider
1619 .stream(&[Message::user("hi".to_string())], &[], None, None)
1620 .await
1621 .expect("partial content must be returned, not Err");
1622 assert_eq!(
1623 completion.content, "Hello partial",
1624 "accumulated deltas must survive the mid-stream read error",
1625 );
1626 assert_eq!(
1627 completion.finish_reason.as_deref(),
1628 Some("interrupted"),
1629 "partial completion must carry the interrupted sentinel",
1630 );
1631 assert!(
1632 completion.tool_calls.is_empty(),
1633 "half-received tool calls must be dropped from a partial completion",
1634 );
1635 }
1636
1637 #[tokio::test]
1638 async fn test_e2_stream_thinking_deltas_populate_reasoning() {
1639 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1642 let addr = listener.local_addr().unwrap();
1643
1644 std::thread::spawn(move || {
1645 let (mut stream, _) = listener.accept().unwrap();
1646 use std::io::{Read, Write};
1647 let mut buf = [0u8; 4096];
1648 let _ = stream.read(&mut buf);
1649 let body = "\
1650event: message_start
1651data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"deepseek-v4-flash\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}
1652
1653event: content_block_start
1654data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}
1655
1656event: content_block_delta
1657data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Step one. \"}}
1658
1659event: content_block_delta
1660data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Step two.\"}}
1661
1662event: content_block_start
1663data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}
1664
1665event: content_block_delta
1666data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Done.\"}}
1667
1668event: message_delta
1669data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}
1670
1671event: message_stop
1672data: {\"type\":\"message_stop\"}
1673
1674";
1675 write!(
1676 stream,
1677 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1678 body.len(),
1679 body,
1680 ).unwrap();
1681 stream.flush().unwrap();
1682 });
1683
1684 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1685
1686 let provider =
1687 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "deepseek-v4-flash")
1688 .unwrap();
1689 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1690 let completion = provider
1691 .stream(&[Message::user("hi".to_string())], &[], Some(tx), None)
1692 .await
1693 .unwrap();
1694
1695 assert_eq!(completion.content, "Done.");
1696 assert_eq!(
1697 completion.reasoning_content.as_deref(),
1698 Some("Step one. Step two.")
1699 );
1700
1701 let mut chunks = Vec::new();
1704 while let Ok(d) = rx.try_recv() {
1705 chunks.push(d);
1706 }
1707 assert_eq!(
1708 chunks,
1709 vec![
1710 StreamChunk::Reasoning("Step one. ".to_string()),
1711 StreamChunk::Reasoning("Step two.".to_string()),
1712 StreamChunk::Text("Done.".to_string()),
1713 ]
1714 );
1715 }
1716
1717 #[tokio::test]
1718 async fn test_f_stream_tool_use_assembles_tool_calls() {
1719 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1722 let addr = listener.local_addr().unwrap();
1723
1724 std::thread::spawn(move || {
1725 let (mut stream, _) = listener.accept().unwrap();
1726 use std::io::{Read, Write};
1727 let mut buf = [0u8; 4096];
1728 let _ = stream.read(&mut buf);
1729 let body = "\
1730event: message_start
1731data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":50,\"output_tokens\":0}}}
1732
1733event: content_block_start
1734data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_abc123\",\"name\":\"Read\",\"input\":{}}}
1735
1736event: content_block_delta
1737data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\":\\\"src/\"}}
1738
1739event: content_block_delta
1740data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"lib.rs\\\"}\"}}
1741
1742event: message_delta
1743data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":30}}
1744
1745event: message_stop
1746data: {\"type\":\"message_stop\"}
1747
1748";
1749 write!(
1750 stream,
1751 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1752 body.len(),
1753 body,
1754 ).unwrap();
1755 stream.flush().unwrap();
1756 });
1757
1758 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1759
1760 let provider =
1761 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
1762 let completion = provider
1763 .stream(
1764 &[Message::user("read the file".to_string())],
1765 &[],
1766 None,
1767 None,
1768 )
1769 .await
1770 .unwrap();
1771 assert_eq!(completion.tool_calls.len(), 1);
1772 let tc = &completion.tool_calls[0];
1773 assert_eq!(tc.id, "call_abc123");
1774 assert_eq!(tc.name, "Read");
1775 assert_eq!(tc.arguments["path"], "src/lib.rs");
1776 assert_eq!(completion.finish_reason.as_deref(), Some("tool_calls"));
1777 }
1778
1779 #[tokio::test]
1780 async fn test_g_stream_tx_receives_text_chunks() {
1781 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1784 let addr = listener.local_addr().unwrap();
1785
1786 std::thread::spawn(move || {
1787 let (mut stream, _) = listener.accept().unwrap();
1788 use std::io::{Read, Write};
1789 let mut buf = [0u8; 4096];
1790 let _ = stream.read(&mut buf);
1791 let body = "\
1792event: message_start
1793data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}
1794
1795event: content_block_start
1796data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"A\"}}
1797
1798event: content_block_delta
1799data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"B\"}}
1800
1801event: content_block_delta
1802data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"C\"}}
1803
1804event: message_delta
1805data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":3}}
1806
1807event: message_stop
1808data: {\"type\":\"message_stop\"}
1809
1810";
1811 write!(
1812 stream,
1813 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1814 body.len(),
1815 body,
1816 ).unwrap();
1817 stream.flush().unwrap();
1818 });
1819
1820 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1821
1822 let provider =
1823 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
1824 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1825 let completion = provider
1826 .stream(&[Message::user("hi".to_string())], &[], Some(tx), None)
1827 .await
1828 .unwrap();
1829 assert_eq!(completion.content, "ABC");
1830
1831 let mut deltas = Vec::new();
1833 while let Some(d) = rx.recv().await {
1834 deltas.push(d);
1835 if deltas.len() >= 3 {
1836 break;
1837 }
1838 }
1839 assert_eq!(
1840 deltas,
1841 vec![
1842 StreamChunk::Text("A".to_string()),
1843 StreamChunk::Text("B".to_string()),
1844 StreamChunk::Text("C".to_string()),
1845 ]
1846 );
1847 }
1848
1849 #[tokio::test]
1850 async fn test_h_stream_with_end_turn_produces_stop_reason() {
1851 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1854 let addr = listener.local_addr().unwrap();
1855
1856 std::thread::spawn(move || {
1857 let (mut stream, _) = listener.accept().unwrap();
1858 use std::io::{Read, Write};
1859 let mut buf = [0u8; 4096];
1860 let _ = stream.read(&mut buf);
1861 let body = "\
1862event: message_start
1863data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}
1864
1865event: content_block_start
1866data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"Hello\"}}
1867
1868event: message_delta
1869data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}
1870
1871event: message_stop
1872data: {\"type\":\"message_stop\"}
1873
1874";
1875 write!(
1876 stream,
1877 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1878 body.len(),
1879 body,
1880 ).unwrap();
1881 stream.flush().unwrap();
1882 });
1883
1884 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1885
1886 let provider =
1887 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
1888 let completion = provider
1889 .stream(&[Message::user("hi".to_string())], &[], None, None)
1890 .await
1891 .unwrap();
1892 assert_eq!(completion.content, "Hello");
1893 assert_eq!(completion.finish_reason.as_deref(), Some("stop"));
1894 assert!(completion.usage.is_some());
1895 }
1896
1897 #[test]
1898 fn policy_retries_5xx_with_exponential_backoff() {
1899 let policy = RetryPolicy::default();
1900 assert_eq!(
1901 policy.backoff_for(0, Some(503), false),
1902 Some(Duration::from_secs(1))
1903 );
1904 assert_eq!(
1905 policy.backoff_for(1, Some(500), false),
1906 Some(Duration::from_secs(2))
1907 );
1908 assert_eq!(policy.backoff_for(2, Some(500), false), None);
1909 }
1910
1911 #[test]
1912 fn policy_retries_network_errors() {
1913 let policy = RetryPolicy::default();
1914 assert_eq!(
1915 policy.backoff_for(0, None, true),
1916 Some(Duration::from_secs(1))
1917 );
1918 }
1919
1920 #[test]
1921 fn policy_does_not_retry_4xx() {
1922 let policy = RetryPolicy::default();
1923 assert_eq!(policy.backoff_for(0, Some(400), false), None);
1924 assert_eq!(policy.backoff_for(0, Some(401), false), None);
1925 assert_eq!(policy.backoff_for(0, Some(404), false), None);
1926 }
1927
1928 #[test]
1935 fn build_request_serializes_passed_specs_verbatim() {
1936 let specs = vec![
1937 ToolSpec {
1938 name: "ToolSearchTool".to_string(),
1939 description: "search".to_string(),
1940 parameters: json!({"type": "object"}),
1941 },
1942 ToolSpec {
1943 name: "Read".to_string(),
1944 description: "Read a file".to_string(),
1945 parameters: json!({"type": "object"}),
1946 },
1947 ];
1948 let body = build_request("claude-3", 0.2, 4096, None, &[Message::user("hi")], &specs);
1949 let tools = body["tools"].as_array().expect("tools should be array");
1950 assert_eq!(tools.len(), 2);
1951 let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
1952 assert!(names.contains(&"ToolSearchTool"));
1953 assert!(names.contains(&"Read"));
1954 for t in tools {
1955 assert!(t["input_schema"].is_object());
1956 }
1957 }
1958
1959 #[test]
1960 fn serialize_messages_encodes_toolsearch_result_as_tool_references() {
1961 let msgs = vec![
1965 Message::user("search for foo"),
1966 Message::assistant_with_tool_calls(
1967 String::new(),
1968 vec![ToolCall {
1969 id: "call_xyz".to_string(),
1970 name: TOOL_SEARCH_TOOL_NAME.to_string(),
1971 arguments: json!({"query": "foo"}),
1972 }],
1973 ),
1974 Message {
1975 role: Role::User,
1976 content: r#"["notebook_edit"]"#.to_string(),
1977 tool_calls: Vec::new(),
1978 tool_call_id: Some("call_xyz".to_string()),
1979 reasoning_content: None,
1980 is_compaction_summary: false,
1981 },
1982 ];
1983
1984 let wire_msgs = serialize_messages_anthropic(&msgs);
1985 assert_eq!(wire_msgs.len(), 3);
1986 let last = &wire_msgs[2];
1987 assert_eq!(last["role"], "user");
1988 let content = last["content"].as_array().expect("content should be array");
1989 assert_eq!(content[0]["type"], "tool_result");
1990 assert_eq!(content[0]["tool_use_id"], "call_xyz");
1991 let inner = content[0]["content"]
1992 .as_array()
1993 .expect("inner content must be array");
1994 assert_eq!(inner.len(), 1);
1995 assert_eq!(inner[0]["type"], "tool_reference");
1996 assert_eq!(inner[0]["tool_name"], "notebook_edit");
1997 }
1998
1999 #[tokio::test]
2000 async fn complete_sends_specs_and_returns_tool_call() {
2001 use std::io::{Read, Write};
2006
2007 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2008 let addr = listener.local_addr().unwrap();
2009
2010 std::thread::spawn(move || {
2011 let (mut stream, _) = listener.accept().unwrap();
2012 let mut buf = [0u8; 8192];
2013 let _ = stream.read(&mut buf);
2014 let response_body = r#"{"type":"message","content":[{"type":"tool_use","id":"call_1","name":"Read","input":{"path":"foo.txt"}}],"stop_reason":"tool_use","usage":{"input_tokens":50,"output_tokens":10}}"#;
2015 let response = format!(
2016 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2017 response_body.len(),
2018 response_body,
2019 );
2020 stream.write_all(response.as_bytes()).unwrap();
2021 });
2022
2023 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2024 let provider =
2025 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet").unwrap();
2026
2027 let specs = vec![
2028 ToolSpec {
2029 name: "ToolSearchTool".to_string(),
2030 description: "search for deferred tools".to_string(),
2031 parameters: json!({"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}),
2032 },
2033 ToolSpec {
2034 name: "Read".to_string(),
2035 description: "Read a file".to_string(),
2036 parameters: json!({"type": "object"}),
2037 },
2038 ];
2039
2040 let result = provider
2041 .complete(&[Message::user("read foo.txt")], &specs)
2042 .await
2043 .expect("should succeed");
2044
2045 assert_eq!(result.tool_calls.len(), 1);
2046 assert_eq!(result.tool_calls[0].name, "Read");
2047 }
2048
2049 #[test]
2050 fn filter_leading_assistant_removes_multiple_empty_assistants() {
2051 let msgs = vec![
2053 Message::assistant("first empty".to_string()),
2054 Message::assistant("second empty".to_string()),
2055 Message::user("Hello".to_string()),
2056 ];
2057 let filtered = filter_leading_assistant(&msgs);
2058 assert_eq!(
2059 filtered.len(),
2060 1,
2061 "both leading assistant messages must be removed"
2062 );
2063 assert_eq!(filtered[0].role, Role::User);
2064 }
2065
2066 #[test]
2067 fn filter_leading_assistant_noop_when_first_is_user() {
2068 let msgs = vec![
2070 Message::user("Hello".to_string()),
2071 Message::assistant("Hi".to_string()),
2072 ];
2073 let filtered = filter_leading_assistant(&msgs);
2074 assert_eq!(
2075 filtered.len(),
2076 2,
2077 "must not remove messages when first is user"
2078 );
2079 assert_eq!(filtered[0].role, Role::User);
2080 }
2081
2082 #[test]
2083 fn serialize_message_plain_user_has_role_user_and_content() {
2084 let msg = Message::user("Hello world".to_string());
2086 let v = serialize_message(&msg);
2087 assert_eq!(v["role"].as_str(), Some("user"));
2088 assert_eq!(v["content"].as_str(), Some("Hello world"));
2089 }
2090
2091 #[test]
2092 fn handle_text_delta_appends_content_and_skips_empty() {
2093 let mut acc = SseAccum::default();
2094 let delta = json!({ "type": "text_delta", "text": "hello" });
2095 AnthropicProvider::handle_text_delta(&mut acc, &delta, &None);
2096 assert_eq!(acc.content, "hello");
2097
2098 let empty_delta = json!({ "type": "text_delta", "text": "" });
2099 AnthropicProvider::handle_text_delta(&mut acc, &empty_delta, &None);
2100 assert_eq!(acc.content, "hello", "empty text must not change content");
2101 }
2102
2103 #[test]
2104 fn handle_input_json_delta_fills_tool_call_slot() {
2105 let mut acc = SseAccum::default();
2106 let delta = json!({ "type": "input_json_delta", "partial_json": r#"{"pa"# });
2107 let parsed = json!({ "index": 0, "delta": delta });
2108 AnthropicProvider::handle_input_json_delta(&mut acc, &delta, &parsed);
2109 assert_eq!(acc.tool_calls.len(), 1);
2110 assert_eq!(acc.tool_calls[0].partial_json, r#"{"pa"#);
2111
2112 let delta2 = json!({ "type": "input_json_delta", "partial_json": r#"th":"x"}"# });
2113 let parsed2 = json!({ "index": 0, "delta": delta2 });
2114 AnthropicProvider::handle_input_json_delta(&mut acc, &delta2, &parsed2);
2115 assert_eq!(acc.tool_calls[0].partial_json, r#"{"path":"x"}"#);
2116 }
2117
2118 #[test]
2119 fn handle_thinking_delta_appends_reasoning_and_skips_empty() {
2120 let mut acc = SseAccum::default();
2121 let delta = json!({ "type": "thinking_delta", "thinking": "step 1" });
2122 AnthropicProvider::handle_thinking_delta(&mut acc, &delta, &None);
2123 assert_eq!(acc.reasoning_content, "step 1");
2124
2125 let empty = json!({ "type": "thinking_delta", "thinking": "" });
2126 AnthropicProvider::handle_thinking_delta(&mut acc, &empty, &None);
2127 assert_eq!(
2128 acc.reasoning_content, "step 1",
2129 "empty thinking must not change reasoning"
2130 );
2131 }
2132
2133 #[test]
2134 fn supports_deferred_tools_default_false_and_env_override() {
2135 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2140 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2141 let provider = AnthropicProvider::new("http://third-party:9999", "sk-noop", "m").unwrap();
2142 let orig = std::env::var("RECURSIVE_DEFERRED_TOOLS").ok();
2143 std::env::remove_var("RECURSIVE_DEFERRED_TOOLS");
2144 assert!(
2145 !provider.supports_deferred_tools(),
2146 "non-official base URL + env unset must be false"
2147 );
2148 std::env::set_var("RECURSIVE_DEFERRED_TOOLS", "1");
2149 assert!(
2150 provider.supports_deferred_tools(),
2151 "env '1' must override to true"
2152 );
2153 std::env::set_var("RECURSIVE_DEFERRED_TOOLS", "true");
2154 assert!(
2155 provider.supports_deferred_tools(),
2156 "env 'true' must override to true"
2157 );
2158 match orig {
2159 Some(v) => std::env::set_var("RECURSIVE_DEFERRED_TOOLS", v),
2160 None => std::env::remove_var("RECURSIVE_DEFERRED_TOOLS"),
2161 }
2162 }
2163
2164 #[test]
2165 fn serialize_messages_keeps_user_message_after_tool_result() {
2166 let msgs = vec![
2171 Message::tool_result("call_1".to_string(), "done".to_string()),
2172 Message::user("next question".to_string()),
2173 ];
2174 let wire = serialize_messages_anthropic(&msgs);
2175 assert_eq!(
2176 wire.len(),
2177 2,
2178 "tool result AND following user message must both be serialized; got {wire:?}"
2179 );
2180 assert_eq!(wire[0]["role"], "user");
2181 assert_eq!(wire[1]["role"], "user");
2182 assert_eq!(wire[1]["content"], "next question");
2183 }
2184
2185 #[tokio::test]
2186 async fn post_with_retry_gives_up_after_max_transient_http_retries() {
2187 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2193 let addr = listener.local_addr().unwrap();
2194 std::thread::spawn(move || {
2195 use std::io::{Read, Write};
2196 loop {
2197 if let Ok((mut stream, _)) = listener.accept() {
2198 let mut buf = [0u8; 4096];
2199 let _ = stream.read(&mut buf);
2200 let body = r#"{"error":"boom"}"#;
2201 write!(
2202 stream,
2203 "HTTP/1.1 503 Service Unavailable\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2204 body.len(),
2205 body
2206 )
2207 .unwrap();
2208 stream.flush().unwrap();
2209 }
2210 }
2211 });
2212 tokio::time::sleep(Duration::from_millis(50)).await;
2213
2214 let mut provider =
2215 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2216 provider.retry = RetryPolicy {
2217 max_retries: 2,
2218 initial_backoff: Duration::from_millis(5),
2219 max_backoff: Duration::from_millis(10),
2220 };
2221 let result = provider
2222 .post_with_retry(&format!("http://{addr}/v1/messages"), &json!({}))
2223 .await;
2224 assert!(
2225 result.is_err(),
2226 "must give up after max_retries transient HTTP failures"
2227 );
2228 }
2229
2230 #[tokio::test]
2231 async fn post_with_retry_gives_up_after_max_network_retries() {
2232 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2237 let addr = listener.local_addr().unwrap();
2238 std::thread::spawn(move || {
2239 for _ in 0..3 {
2240 if let Ok((stream, _)) = listener.accept() {
2241 drop(stream);
2242 }
2243 }
2244 });
2245 tokio::time::sleep(Duration::from_millis(50)).await;
2246
2247 let mut provider =
2248 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2249 provider.retry = RetryPolicy {
2250 max_retries: 2,
2251 initial_backoff: Duration::from_millis(5),
2252 max_backoff: Duration::from_millis(10),
2253 };
2254 let result = provider
2255 .post_with_retry(&format!("http://{addr}/v1/messages"), &json!({}))
2256 .await;
2257 assert!(
2258 result.is_err(),
2259 "must give up after max_retries network failures"
2260 );
2261 }
2262
2263 #[tokio::test]
2264 async fn stream_inner_gives_up_after_max_transient_http_retries() {
2265 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2269 let addr = listener.local_addr().unwrap();
2270 std::thread::spawn(move || {
2271 use std::io::{Read, Write};
2272 loop {
2273 if let Ok((mut stream, _)) = listener.accept() {
2274 let mut buf = [0u8; 4096];
2275 let _ = stream.read(&mut buf);
2276 let body = r#"{"error":"boom"}"#;
2277 write!(
2278 stream,
2279 "HTTP/1.1 503 Service Unavailable\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2280 body.len(),
2281 body
2282 )
2283 .unwrap();
2284 stream.flush().unwrap();
2285 }
2286 }
2287 });
2288 tokio::time::sleep(Duration::from_millis(50)).await;
2289
2290 let mut provider =
2291 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2292 provider.retry = RetryPolicy {
2293 max_retries: 2,
2294 initial_backoff: Duration::from_millis(5),
2295 max_backoff: Duration::from_millis(10),
2296 };
2297 let result = provider
2298 .stream_inner(&[Message::user("hi".to_string())], &[], None, None)
2299 .await;
2300 assert!(
2301 result.is_err(),
2302 "stream_inner must give up after max_retries transient HTTP failures"
2303 );
2304 }
2305
2306 #[tokio::test]
2307 async fn stream_inner_gives_up_after_max_network_retries() {
2308 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2311 let addr = listener.local_addr().unwrap();
2312 std::thread::spawn(move || {
2313 for _ in 0..3 {
2314 if let Ok((stream, _)) = listener.accept() {
2315 drop(stream);
2316 }
2317 }
2318 });
2319 tokio::time::sleep(Duration::from_millis(50)).await;
2320
2321 let mut provider =
2322 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2323 provider.retry = RetryPolicy {
2324 max_retries: 2,
2325 initial_backoff: Duration::from_millis(5),
2326 max_backoff: Duration::from_millis(10),
2327 };
2328 let result = provider
2329 .stream_inner(&[Message::user("hi".to_string())], &[], None, None)
2330 .await;
2331 assert!(
2332 result.is_err(),
2333 "stream_inner must give up after max_retries network failures"
2334 );
2335 }
2336
2337 #[tokio::test]
2338 async fn parse_sse_stream_returns_partial_when_only_reasoning_accumulated() {
2339 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2346 let addr = listener.local_addr().unwrap();
2347 std::thread::spawn(move || {
2348 let (mut stream, _) = listener.accept().unwrap();
2349 use std::io::{Read, Write};
2350 let mut buf = [0u8; 4096];
2351 let _ = stream.read(&mut buf);
2352 let body = "\
2353event: content_block_start
2354data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"Let me reason\"}}
2355";
2356 write!(
2357 stream,
2358 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2359 9999,
2360 body,
2361 )
2362 .unwrap();
2363 stream.flush().unwrap();
2364 });
2365 tokio::time::sleep(Duration::from_millis(50)).await;
2366
2367 let provider = AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2368 let completion = provider
2369 .stream(&[Message::user("hi".to_string())], &[], None, None)
2370 .await
2371 .expect("reasoning-only partial must be returned, not Err");
2372 assert_eq!(completion.finish_reason.as_deref(), Some("interrupted"));
2373 assert_eq!(completion.content, "", "no text content was streamed");
2374 assert_eq!(
2375 completion.reasoning_content.as_deref(),
2376 Some("Let me reason"),
2377 "accumulated thinking must survive the mid-stream error"
2378 );
2379 }
2380
2381 #[tokio::test]
2382 async fn parse_sse_stream_returns_partial_when_only_tool_call_started() {
2383 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2389 let addr = listener.local_addr().unwrap();
2390 std::thread::spawn(move || {
2391 let (mut stream, _) = listener.accept().unwrap();
2392 use std::io::{Read, Write};
2393 let mut buf = [0u8; 4096];
2394 let _ = stream.read(&mut buf);
2395 let body = "\
2396event: content_block_start
2397data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"Read\",\"input\":{}}}
2398";
2399 write!(
2400 stream,
2401 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2402 9999,
2403 body,
2404 )
2405 .unwrap();
2406 stream.flush().unwrap();
2407 });
2408 tokio::time::sleep(Duration::from_millis(50)).await;
2409
2410 let provider = AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2411 let completion = provider
2412 .stream(&[Message::user("hi".to_string())], &[], None, None)
2413 .await
2414 .expect("tool-call-only partial must be returned, not Err");
2415 assert_eq!(completion.finish_reason.as_deref(), Some("interrupted"));
2416 assert_eq!(completion.content, "", "no text content was streamed");
2417 assert!(
2418 completion.tool_calls.is_empty(),
2419 "half-received tool calls must be dropped from a partial completion"
2420 );
2421 }
2422
2423 #[tokio::test]
2424 async fn parse_sse_stream_processes_trailing_line_without_newline() {
2425 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2429 let addr = listener.local_addr().unwrap();
2430 std::thread::spawn(move || {
2431 let (mut stream, _) = listener.accept().unwrap();
2432 use std::io::{Read, Write};
2433 let mut buf = [0u8; 4096];
2434 let _ = stream.read(&mut buf);
2435 let body = "\
2437event: content_block_start
2438data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"Hello\"}}
2439
2440event: content_block_delta
2441data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}";
2442 write!(
2443 stream,
2444 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2445 body.len(),
2446 body,
2447 )
2448 .unwrap();
2449 stream.flush().unwrap();
2450 });
2451 tokio::time::sleep(Duration::from_millis(50)).await;
2452
2453 let provider = AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2454 let completion = provider
2455 .stream(&[Message::user("hi".to_string())], &[], None, None)
2456 .await
2457 .expect("stream should complete");
2458 assert_eq!(
2459 completion.content, "Hello world",
2460 "trailing line without newline must still be processed"
2461 );
2462 }
2463
2464 #[tokio::test]
2465 async fn build_completion_usage_from_input_tokens_only() {
2466 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2471 let addr = listener.local_addr().unwrap();
2472 std::thread::spawn(move || {
2473 let (mut stream, _) = listener.accept().unwrap();
2474 use std::io::{Read, Write};
2475 let mut buf = [0u8; 4096];
2476 let _ = stream.read(&mut buf);
2477 let body = "\
2478event: message_start
2479data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"m\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":7,\"output_tokens\":0}}}
2480
2481event: content_block_start
2482data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"hi\"}}
2483";
2484 write!(
2485 stream,
2486 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2487 body.len(),
2488 body,
2489 )
2490 .unwrap();
2491 stream.flush().unwrap();
2492 });
2493 tokio::time::sleep(Duration::from_millis(50)).await;
2494
2495 let provider = AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "m").unwrap();
2496 let completion = provider
2497 .stream(&[Message::user("hi".to_string())], &[], None, None)
2498 .await
2499 .expect("stream should complete");
2500 let usage = completion
2501 .usage
2502 .expect("usage must be present when input_tokens was reported");
2503 assert_eq!(usage.prompt_tokens, 7);
2504 }
2505
2506 #[test]
2507 fn process_sse_line_thinking_block_start_populates_reasoning() {
2508 let provider = AnthropicProvider::new("http://localhost:0", "sk-noop", "m").unwrap();
2512 let mut acc = SseAccum::default();
2513 provider
2514 .process_sse_line("event: content_block_start", &mut acc, &None)
2515 .unwrap();
2516 let line = r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"initial thought"}}"#;
2517 provider.process_sse_line(line, &mut acc, &None).unwrap();
2518 assert_eq!(acc.reasoning_content, "initial thought");
2519 }
2520
2521 #[test]
2522 fn message_delta_usage_overrides_message_start_input() {
2523 let provider = AnthropicProvider::new("http://localhost:0", "sk-noop", "m").unwrap();
2527 let mut acc = SseAccum::default();
2528 provider
2529 .process_sse_line("event: message_start", &mut acc, &None)
2530 .unwrap();
2531 provider
2532 .process_sse_line(
2533 r#"data: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","content":[],"model":"GLM-5.2","usage":{"input_tokens":0,"output_tokens":0}}}"#,
2534 &mut acc,
2535 &None,
2536 )
2537 .unwrap();
2538 provider
2539 .process_sse_line("event: message_delta", &mut acc, &None)
2540 .unwrap();
2541 provider
2542 .process_sse_line(
2543 r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":17,"output_tokens":3,"cache_read_input_tokens":7680,"cache_creation_input_tokens":36}}"#,
2544 &mut acc,
2545 &None,
2546 )
2547 .unwrap();
2548 let completion = AnthropicProvider::build_completion(acc, Some("end_turn".to_string()));
2549 let usage = completion.usage.expect("delta 帧报了 usage,必须存在");
2550 assert_eq!(usage.prompt_tokens, 17);
2551 assert_eq!(usage.completion_tokens, 3);
2552 assert_eq!(usage.cache_hit_tokens, 7680);
2553 assert_eq!(usage.cache_miss_tokens, 17 + 36);
2555 assert_eq!(usage.total_tokens, 20);
2556 }
2557
2558 #[test]
2559 fn message_delta_without_input_keys_keeps_official_semantics() {
2560 let provider = AnthropicProvider::new("http://localhost:0", "sk-noop", "m").unwrap();
2563 let mut acc = SseAccum::default();
2564 provider
2565 .process_sse_line("event: message_start", &mut acc, &None)
2566 .unwrap();
2567 provider
2568 .process_sse_line(
2569 r#"data: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","content":[],"model":"claude-3","usage":{"input_tokens":50,"output_tokens":0,"cache_read_input_tokens":100}}}"#,
2570 &mut acc,
2571 &None,
2572 )
2573 .unwrap();
2574 provider
2575 .process_sse_line("event: message_delta", &mut acc, &None)
2576 .unwrap();
2577 provider
2578 .process_sse_line(
2579 r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":7}}"#,
2580 &mut acc,
2581 &None,
2582 )
2583 .unwrap();
2584 let completion = AnthropicProvider::build_completion(acc, Some("end_turn".to_string()));
2585 let usage = completion.usage.expect("message_start 报了 usage");
2586 assert_eq!(usage.prompt_tokens, 50);
2587 assert_eq!(usage.completion_tokens, 7);
2588 assert_eq!(usage.cache_hit_tokens, 100);
2589 }
2590}