1use std::sync::Arc;
7use std::time::Duration;
8
9use async_trait::async_trait;
10use reqwest::Client;
11use serde::Deserialize;
12use serde_json::{json, Value};
13
14use super::search::{KeywordSearchEngine, SpecWithHint, ToolSearchEngine};
15use super::{Completion, LlmProvider, RetryPolicy, StreamSender, TokenUsage, ToolCall, ToolSpec};
16
17pub(crate) const TOOL_SEARCH_TOOL_NAME: &str = "ToolSearchTool";
22
23const MAX_SEARCH_ROUNDS: usize = 3;
27use crate::error::{Error, Result};
28use crate::message::{Message, Role};
29
30#[derive(Debug, Clone)]
31pub struct AnthropicProvider {
32 base_url: String,
33 api_key: String,
34 model: String,
35 client: Client,
36 temperature: f64,
37 max_tokens: u32,
38 retry: RetryPolicy,
39 search_engine: Arc<dyn ToolSearchEngine>,
43}
44
45impl AnthropicProvider {
46 pub fn new(
47 base_url: impl Into<String>,
48 api_key: impl Into<String>,
49 model: impl Into<String>,
50 ) -> Self {
51 Self {
52 base_url: base_url.into().trim_end_matches('/').to_string(),
53 api_key: api_key.into(),
54 model: model.into(),
55 client: Client::builder()
56 .timeout(Duration::from_secs(180))
57 .build()
58 .expect("reqwest client build"),
59 temperature: 0.2,
60 max_tokens: 4096,
61 retry: RetryPolicy::default(),
62 search_engine: Arc::new(KeywordSearchEngine::new()),
63 }
64 }
65
66 fn make_err(&self, ctx: impl Into<String>) -> Error {
68 Error::Llm {
69 provider: self.model.clone(),
70 message: ctx.into(),
71 }
72 }
73
74 pub fn with_temperature(mut self, t: f64) -> Self {
75 self.temperature = t;
76 self
77 }
78
79 pub fn with_max_tokens(mut self, n: u32) -> Self {
80 self.max_tokens = n;
81 self
82 }
83
84 pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
85 self.retry = policy;
86 self
87 }
88
89 pub fn with_search_engine(mut self, engine: Arc<dyn ToolSearchEngine>) -> Self {
93 self.search_engine = engine;
94 self
95 }
96
97 fn tool_search_spec() -> (ToolSpec, Option<String>) {
101 let description = "Fetches full schema definitions for deferred tools so they can be \
102 called. Until fetched, only the name is known — there is no parameter schema, so \
103 the tool cannot be invoked. Use `select:<tool_name>` for direct selection, or \
104 keywords to search."
105 .to_string();
106 let search_hint = Some("find search discover discover tools lookup".to_string());
107 let spec = ToolSpec {
108 name: TOOL_SEARCH_TOOL_NAME.to_string(),
109 description,
110 parameters: json!({
111 "type": "object",
112 "properties": {
113 "query": {
114 "type": "string",
115 "description": "Query to find deferred tools. Use \"select:<tool_name>\" \
116 for direct selection, or keywords to search."
117 },
118 "max_results": {
119 "type": "integer",
120 "description": "Maximum number of results to return (default: 5)",
121 "default": 5
122 }
123 },
124 "required": ["query"]
125 }),
126 };
127 (spec, search_hint)
128 }
129
130 async fn run_search_aware_loop(
147 &self,
148 messages: &[Message],
149 eager_tools: &[SpecWithHint],
150 deferred_tools: &[SpecWithHint],
151 pending_tool_result: Option<(&str, &[String])>,
152 round: usize,
153 ) -> Result<Completion> {
154 let mut all_eager: Vec<SpecWithHint> = Vec::with_capacity(eager_tools.len() + 1);
156 all_eager.push(Self::tool_search_spec());
157 all_eager.extend_from_slice(eager_tools);
158
159 let (system, msgs) = extract_system_message(messages);
160 let msgs = filter_leading_assistant(&msgs);
161
162 let body = build_request_with_partition(
163 &self.model,
164 self.temperature,
165 self.max_tokens,
166 system.as_deref(),
167 &msgs,
168 &all_eager,
169 deferred_tools,
170 pending_tool_result,
171 );
172 let url = format!("{}/v1/messages", self.base_url);
173
174 let text = self.post_with_retry(&url, &body).await?;
175 let parsed: AnthropicResponse = serde_json::from_str(&text)
176 .map_err(|e| self.make_err(format!("failed to parse response: {e}; body: {text}")))?;
177 let completion = parse_completion(parsed);
178
179 if let Some(search_call) = completion
181 .tool_calls
182 .iter()
183 .find(|c| c.name == TOOL_SEARCH_TOOL_NAME)
184 {
185 if round >= MAX_SEARCH_ROUNDS {
186 tracing::warn!(
189 target: "recursive::llm",
190 round,
191 max = MAX_SEARCH_ROUNDS,
192 "ToolSearchTool: hit MAX_SEARCH_ROUNDS, returning current completion"
193 );
194 return Ok(completion);
195 }
196 let query = search_call
197 .arguments
198 .get("query")
199 .and_then(|v| v.as_str())
200 .unwrap_or("");
201 let max_results = search_call
202 .arguments
203 .get("max_results")
204 .and_then(|v| v.as_u64())
205 .map(|n| n as usize);
206 let names = self.search_engine.resolve(query, deferred_tools);
207 let names: Vec<String> = match max_results {
208 Some(n) => names.into_iter().take(n).collect(),
209 None => names,
210 };
211
212 let mut next_messages: Vec<Message> = msgs.clone();
226 let assistant_with_call = Message {
227 role: Role::Assistant,
228 content: completion.content.clone(),
229 tool_calls: completion.tool_calls.clone(),
230 tool_call_id: None,
231 reasoning_content: completion.reasoning_content.clone(),
232 };
233 next_messages.push(assistant_with_call);
234 next_messages.push(Message {
235 role: Role::User,
236 content: String::new(),
237 tool_calls: Vec::new(),
238 tool_call_id: Some(search_call.id.clone()),
239 reasoning_content: None,
240 });
241
242 return Box::pin(self.run_search_aware_loop(
243 &next_messages,
244 eager_tools,
245 deferred_tools,
246 Some((&search_call.id, &names)),
247 round + 1,
248 ))
249 .await;
250 }
251
252 Ok(completion)
253 }
254
255 async fn post_with_retry(&self, url: &str, body: &Value) -> Result<String> {
260 let mut attempt = 0;
261 loop {
262 tracing::debug!(target: "recursive::llm", request = %body, "POST {}", url);
263 let result = self
264 .client
265 .post(url)
266 .header("x-api-key", &self.api_key)
267 .header("anthropic-version", "2023-06-01")
268 .header("content-type", "application/json")
269 .json(body)
270 .send()
271 .await;
272 match result {
273 Ok(resp) => {
274 let status = resp.status();
275 if status.is_success() {
276 return resp.text().await.map_err(Error::from);
277 }
278 let text = resp.text().await?;
279 if let Some(backoff) =
280 self.retry
281 .backoff_for(attempt, Some(status.as_u16()), false)
282 {
283 tracing::warn!(
284 target: "recursive::llm",
285 attempt,
286 backoff_ms = backoff.as_millis(),
287 status = status.as_u16(),
288 "transient HTTP error, retrying"
289 );
290 tokio::time::sleep(backoff).await;
291 attempt += 1;
292 continue;
293 }
294 return Err(self.make_err(format!("HTTP {}: {}", status, text)));
295 }
296 Err(e) => {
297 if let Some(backoff) = self.retry.backoff_for(attempt, None, true) {
298 tracing::warn!(
299 target: "recursive::llm",
300 attempt,
301 backoff_ms = backoff.as_millis(),
302 error = %e,
303 "network error, retrying"
304 );
305 tokio::time::sleep(backoff).await;
306 attempt += 1;
307 continue;
308 }
309 return Err(self.make_err(format!("request failed: {e}")));
310 }
311 }
312 }
313 }
314}
315
316#[async_trait]
317impl LlmProvider for AnthropicProvider {
318 async fn complete(&self, messages: &[Message], tools: &[ToolSpec]) -> Result<Completion> {
319 let (system, messages) = extract_system_message(messages);
320 let messages = filter_leading_assistant(&messages);
321 let body = build_request(
322 &self.model,
323 self.temperature,
324 self.max_tokens,
325 system.as_deref(),
326 &messages,
327 tools,
328 );
329 let url = format!("{}/v1/messages", self.base_url);
330 let text = self.post_with_retry(&url, &body).await?;
331 let parsed: AnthropicResponse = serde_json::from_str(&text)
332 .map_err(|e| self.make_err(format!("failed to parse response: {e}; body: {text}")))?;
333 Ok(parse_completion(parsed))
334 }
335
336 async fn stream(
337 &self,
338 messages: &[Message],
339 tools: &[ToolSpec],
340 stream_tx: Option<StreamSender>,
341 ) -> Result<Completion> {
342 self.stream_inner(messages, tools, stream_tx).await
343 }
344
345 async fn complete_with_search(
350 &self,
351 messages: &[Message],
352 eager_tools: &[(ToolSpec, Option<String>)],
353 deferred_tools: &[(ToolSpec, Option<String>)],
354 ) -> Result<Completion> {
355 self.run_search_aware_loop(messages, eager_tools, deferred_tools, None, 0)
356 .await
357 }
358}
359
360impl AnthropicProvider {
361 async fn stream_inner(
363 &self,
364 messages: &[Message],
365 tools: &[ToolSpec],
366 stream_tx: Option<StreamSender>,
367 ) -> Result<Completion> {
368 let (system, messages) = extract_system_message(messages);
369 let messages = filter_leading_assistant(&messages);
370
371 let mut body = build_request(
372 &self.model,
373 self.temperature,
374 self.max_tokens,
375 system.as_deref(),
376 &messages,
377 tools,
378 );
379 body["stream"] = Value::Bool(true);
380
381 let url = format!("{}/v1/messages", self.base_url);
382
383 let mut attempt = 0;
384 loop {
385 tracing::debug!(target: "recursive::llm", request = %body, "POST {} (stream)", url);
386 let result = self
387 .client
388 .post(&url)
389 .header("x-api-key", &self.api_key)
390 .header("anthropic-version", "2023-06-01")
391 .header("content-type", "application/json")
392 .json(&body)
393 .send()
394 .await;
395
396 match result {
397 Ok(resp) => {
398 let status = resp.status();
399 if status.is_success() {
400 return self.parse_sse_stream(resp, stream_tx.clone()).await;
401 }
402
403 let text = resp.text().await?;
405 tracing::debug!(target: "recursive::llm", body = %text, "error response (stream)");
406
407 if let Some(backoff) =
408 self.retry
409 .backoff_for(attempt, Some(status.as_u16()), false)
410 {
411 tracing::warn!(
412 target: "recursive::llm",
413 attempt,
414 backoff_ms = backoff.as_millis(),
415 status = status.as_u16(),
416 "transient HTTP error, retrying (stream)"
417 );
418 tokio::time::sleep(backoff).await;
419 attempt += 1;
420 continue;
421 }
422
423 return Err(self.make_err(format!("HTTP {}: {}", status, text)));
424 }
425 Err(e) => {
426 if let Some(backoff) = self.retry.backoff_for(attempt, None, true) {
427 tracing::warn!(
428 target: "recursive::llm",
429 attempt,
430 backoff_ms = backoff.as_millis(),
431 error = %e,
432 "network error, retrying (stream)"
433 );
434 tokio::time::sleep(backoff).await;
435 attempt += 1;
436 continue;
437 }
438
439 return Err(self.make_err(format!("request failed: {e}")));
440 }
441 }
442 }
443 }
444
445 async fn parse_sse_stream(
459 &self,
460 resp: reqwest::Response,
461 stream_tx: Option<StreamSender>,
462 ) -> Result<Completion> {
463 let mut content = String::new();
464 let mut tool_calls: Vec<StreamToolCall> = Vec::new();
465 let mut finish_reason: Option<String> = None;
466 let mut usage: Option<TokenUsage> = None;
467 let mut input_tokens: Option<u32> = None;
468 let mut output_tokens: Option<u32> = None;
469 let mut cache_creation: Option<u32> = None;
470 let mut cache_read: Option<u32> = None;
471
472 let reader = resp.text().await?;
474 let mut current_event: Option<String> = None;
475
476 for line in reader.lines() {
477 if let Some(event_name) = line.strip_prefix("event: ") {
478 current_event = Some(event_name.to_string());
479 continue;
480 }
481
482 if let Some(data) = line.strip_prefix("data: ") {
483 let event_type = current_event.as_deref().unwrap_or("unknown");
484
485 match event_type {
486 "message_start" => {
487 let parsed: Value = serde_json::from_str(data).map_err(|e| {
489 self.make_err(format!(
490 "SSE parse error (message_start): {e}; data: {data}"
491 ))
492 })?;
493 if let Some(msg) = parsed.get("message") {
494 if let Some(u) = msg.get("usage") {
495 input_tokens = u
496 .get("input_tokens")
497 .and_then(|v| v.as_u64())
498 .map(|v| v as u32);
499 cache_creation = u
500 .get("cache_creation_input_tokens")
501 .and_then(|v| v.as_u64())
502 .map(|v| v as u32);
503 cache_read = u
504 .get("cache_read_input_tokens")
505 .and_then(|v| v.as_u64())
506 .map(|v| v as u32);
507 }
508 }
509 }
510 "content_block_start" => {
511 let parsed: Value = serde_json::from_str(data).map_err(|e| {
512 self.make_err(format!(
513 "SSE parse error (content_block_start): {e}; data: {data}"
514 ))
515 })?;
516 if let Some(block) = parsed.get("content_block") {
517 let block_type =
518 block.get("type").and_then(|v| v.as_str()).unwrap_or("");
519 let index =
520 parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
521 match block_type {
522 "tool_use" => {
523 let id = block
524 .get("id")
525 .and_then(|v| v.as_str())
526 .unwrap_or("")
527 .to_string();
528 let name = block
529 .get("name")
530 .and_then(|v| v.as_str())
531 .unwrap_or("")
532 .to_string();
533 while tool_calls.len() <= index {
535 tool_calls.push(StreamToolCall::default());
536 }
537 tool_calls[index].id = id;
538 tool_calls[index].name = name;
539 }
540 "text" => {
541 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
543 if !text.is_empty() {
544 content.push_str(text);
545 if let Some(ref tx) = stream_tx {
546 let _ = tx.send(text.to_string());
547 }
548 }
549 }
550 }
551 _ => {}
552 }
553 }
554 }
555 "content_block_delta" => {
556 let parsed: Value = serde_json::from_str(data).map_err(|e| {
557 self.make_err(format!(
558 "SSE parse error (content_block_delta): {e}; data: {data}"
559 ))
560 })?;
561 if let Some(delta) = parsed.get("delta") {
562 let delta_type =
563 delta.get("type").and_then(|v| v.as_str()).unwrap_or("");
564 match delta_type {
565 "text_delta" => {
566 if let Some(text) = delta.get("text").and_then(|v| v.as_str()) {
567 if !text.is_empty() {
568 content.push_str(text);
569 if let Some(ref tx) = stream_tx {
570 let _ = tx.send(text.to_string());
571 }
572 }
573 }
574 }
575 "input_json_delta" => {
576 if let Some(partial) =
577 delta.get("partial_json").and_then(|v| v.as_str())
578 {
579 let index = parsed
580 .get("index")
581 .and_then(|v| v.as_u64())
582 .unwrap_or(0)
583 as usize;
584 while tool_calls.len() <= index {
585 tool_calls.push(StreamToolCall::default());
586 }
587 tool_calls[index].partial_json.push_str(partial);
588 }
589 }
590 _ => {}
591 }
592 }
593 }
594 "message_delta" => {
595 let parsed: Value = serde_json::from_str(data).map_err(|e| {
596 self.make_err(format!(
597 "SSE parse error (message_delta): {e}; data: {data}"
598 ))
599 })?;
600 if let Some(delta) = parsed.get("delta") {
601 if let Some(reason) = delta.get("stop_reason").and_then(|v| v.as_str())
602 {
603 finish_reason = Some(reason.to_string());
604 }
605 }
606 if let Some(u) = parsed.get("usage") {
607 output_tokens = u
608 .get("output_tokens")
609 .and_then(|v| v.as_u64())
610 .map(|v| v as u32);
611 }
612 }
613 "message_stop" => {
614 }
616 "ping" => {
617 }
619 _ => {
620 tracing::debug!(target: "recursive::llm", event = %event_type, data = %data, "unhandled SSE event");
621 }
622 }
623
624 current_event = None;
625 }
626 }
627
628 if input_tokens.is_some() || output_tokens.is_some() {
630 let prompt = input_tokens.unwrap_or(0);
631 let completion = output_tokens.unwrap_or(0);
632 usage = Some(TokenUsage {
633 prompt_tokens: prompt,
634 completion_tokens: completion,
635 total_tokens: prompt.saturating_add(completion),
636 cache_hit_tokens: cache_read.unwrap_or(0),
637 cache_miss_tokens: cache_creation.unwrap_or(0),
638 });
639 }
640
641 let final_tool_calls: Vec<ToolCall> = tool_calls
643 .into_iter()
644 .filter(|tc| !tc.id.is_empty())
645 .map(|tc| {
646 let args: Value = if tc.partial_json.trim().is_empty() {
647 Value::Object(Default::default())
648 } else {
649 serde_json::from_str(&tc.partial_json)
650 .unwrap_or_else(|_| Value::String(tc.partial_json.clone()))
651 };
652 ToolCall {
653 id: tc.id,
654 name: tc.name,
655 arguments: args,
656 }
657 })
658 .collect();
659
660 Ok(Completion {
661 content,
662 tool_calls: final_tool_calls,
663 finish_reason: finish_reason.map(|r| match r.as_str() {
664 "end_turn" => "stop".to_string(),
665 "max_tokens" => "length".to_string(),
666 "tool_use" => "tool_calls".to_string(),
667 other => other.to_string(),
668 }),
669 usage,
670 reasoning_content: None,
671 })
672 }
673}
674
675fn extract_system_message(messages: &[Message]) -> (Option<String>, Vec<Message>) {
677 if messages.first().is_some_and(|m| m.role == Role::System) {
678 let system = messages[0].content.clone();
679 let rest = messages[1..].to_vec();
680 (Some(system), rest)
681 } else {
682 (None, messages.to_vec())
683 }
684}
685
686fn filter_leading_assistant(messages: &[Message]) -> Vec<Message> {
689 let mut result = messages.to_vec();
690 while result
691 .first()
692 .is_some_and(|m| m.role == Role::Assistant && m.tool_calls.is_empty())
693 {
694 result.remove(0);
696 }
697 result
698}
699
700fn build_request(
701 model: &str,
702 temperature: f64,
703 max_tokens: u32,
704 system: Option<&str>,
705 messages: &[Message],
706 tools: &[ToolSpec],
707) -> Value {
708 let mut req = serde_json::json!({
709 "model": model,
710 "max_tokens": max_tokens,
711 "temperature": temperature,
712 });
713
714 if let Some(sys) = system {
715 req["system"] = Value::String(sys.to_string());
716 }
717
718 let msgs: Vec<Value> = messages.iter().map(serialize_message).collect();
719 req["messages"] = Value::Array(msgs);
720
721 if !tools.is_empty() {
722 let tools_json: Vec<Value> = tools
723 .iter()
724 .map(|t| {
725 serde_json::json!({
726 "name": t.name,
727 "description": t.description,
728 "input_schema": t.parameters,
729 })
730 })
731 .collect();
732 req["tools"] = Value::Array(tools_json);
733 }
734
735 req
736}
737
738#[allow(clippy::too_many_arguments)]
751fn build_request_with_partition(
752 model: &str,
753 temperature: f64,
754 max_tokens: u32,
755 system: Option<&str>,
756 messages: &[Message],
757 eager_tools: &[SpecWithHint],
758 deferred_tools: &[SpecWithHint],
759 pending_tool_result: Option<(&str, &[String])>,
760) -> Value {
761 let mut req = serde_json::json!({
762 "model": model,
763 "max_tokens": max_tokens,
764 "temperature": temperature,
765 });
766
767 if let Some(sys) = system {
768 req["system"] = Value::String(sys.to_string());
769 }
770
771 let mut msgs: Vec<Value> = messages.iter().map(serialize_message).collect();
772 if let Some((id, names)) = pending_tool_result {
773 if let Some(last) = msgs.last_mut() {
774 *last = serde_json::json!({
784 "role": "user",
785 "content": [{
786 "type": "tool_result",
787 "tool_use_id": id,
788 "content": tool_reference_array(names),
789 }],
790 });
791 }
792 }
793 req["messages"] = Value::Array(msgs);
794
795 if !eager_tools.is_empty() || !deferred_tools.is_empty() {
796 let mut tools_json: Vec<Value> =
797 Vec::with_capacity(eager_tools.len() + deferred_tools.len());
798 for (spec, _) in eager_tools {
799 tools_json.push(serde_json::json!({
800 "name": spec.name,
801 "description": spec.description,
802 "input_schema": spec.parameters,
803 }));
804 }
805 for (spec, _) in deferred_tools {
806 tools_json.push(serde_json::json!({
807 "name": spec.name,
808 "description": spec.description,
809 "input_schema": spec.parameters,
810 "defer_loading": true,
811 }));
812 }
813 req["tools"] = Value::Array(tools_json);
814 }
815
816 req
817}
818
819fn tool_reference_array(names: &[String]) -> Value {
822 Value::Array(
823 names
824 .iter()
825 .map(|n| {
826 serde_json::json!({
827 "type": "tool_reference",
828 "tool_name": n,
829 })
830 })
831 .collect(),
832 )
833}
834
835fn serialize_message(m: &Message) -> Value {
836 let role = match m.role {
837 Role::System => "system",
838 Role::User => "user",
839 Role::Assistant => "assistant",
840 Role::Tool => "user", };
842
843 if !m.tool_calls.is_empty() {
845 let tool_uses: Vec<Value> = m
846 .tool_calls
847 .iter()
848 .map(|c| {
849 serde_json::json!({
850 "type": "tool_use",
851 "id": c.id,
852 "name": c.name,
853 "input": c.arguments,
854 })
855 })
856 .collect();
857 serde_json::json!({
858 "role": role,
859 "content": tool_uses,
860 })
861 } else if let Some(id) = &m.tool_call_id {
862 serde_json::json!({
864 "role": role,
865 "content": [{
866 "type": "tool_result",
867 "tool_use_id": id,
868 "content": m.content,
869 }],
870 })
871 } else {
872 serde_json::json!({
874 "role": role,
875 "content": m.content,
876 })
877 }
878}
879
880#[derive(Debug, Deserialize)]
881struct AnthropicResponse {
882 #[serde(default)]
883 content: Vec<ContentBlock>,
884 #[serde(default)]
885 usage: Option<AnthropicUsage>,
886 #[serde(default)]
887 stop_reason: Option<String>,
888}
889
890#[derive(Debug, Deserialize)]
891#[serde(tag = "type")]
892enum ContentBlock {
893 #[serde(rename = "text")]
894 Text {
895 #[serde(default)]
896 text: String,
897 },
898 #[serde(rename = "tool_use")]
899 ToolUse {
900 id: String,
901 name: String,
902 #[serde(default)]
903 input: Value,
904 },
905}
906
907#[derive(Debug, Deserialize)]
908struct AnthropicUsage {
909 #[serde(default)]
910 input_tokens: Option<u32>,
911 #[serde(default)]
912 output_tokens: Option<u32>,
913 #[serde(default)]
914 cache_creation_input_tokens: Option<u32>,
915 #[serde(default)]
916 cache_read_input_tokens: Option<u32>,
917}
918
919impl AnthropicUsage {
920 fn to_token_usage(&self) -> TokenUsage {
921 TokenUsage {
922 prompt_tokens: self.input_tokens.unwrap_or(0),
923 completion_tokens: self.output_tokens.unwrap_or(0),
924 total_tokens: self
925 .input_tokens
926 .unwrap_or(0)
927 .saturating_add(self.output_tokens.unwrap_or(0)),
928 cache_hit_tokens: self.cache_read_input_tokens.unwrap_or(0),
929 cache_miss_tokens: self.cache_creation_input_tokens.unwrap_or(0),
930 }
931 }
932}
933
934#[derive(Default)]
936struct StreamToolCall {
937 id: String,
938 name: String,
939 partial_json: String,
940}
941
942fn parse_completion(response: AnthropicResponse) -> Completion {
943 let mut content = String::new();
944 let mut tool_calls = Vec::new();
945
946 for block in response.content {
947 match block {
948 ContentBlock::Text { text } => {
949 content.push_str(&text);
950 }
951 ContentBlock::ToolUse { id, name, input } => {
952 tool_calls.push(ToolCall {
953 id,
954 name,
955 arguments: input,
956 });
957 }
958 }
959 }
960
961 let finish_reason = response.stop_reason.map(|r| match r.as_str() {
962 "end_turn" => "stop".to_string(),
963 "max_tokens" => "length".to_string(),
964 "tool_use" => "tool_calls".to_string(),
965 other => other.to_string(),
966 });
967
968 Completion {
969 content,
970 tool_calls,
971 finish_reason,
972 usage: response.usage.map(|u| u.to_token_usage()),
973 reasoning_content: None,
974 }
975}
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980
981 #[test]
982 fn extract_system_message_when_present() {
983 let msgs = vec![
984 Message::system("You are helpful.".to_string()),
985 Message::user("Hello".to_string()),
986 ];
987 let (system, rest) = extract_system_message(&msgs);
988 assert_eq!(system, Some("You are helpful.".to_string()));
989 assert_eq!(rest.len(), 1);
990 assert_eq!(rest[0].role, Role::User);
991 }
992
993 #[test]
994 fn extract_system_message_when_not_present() {
995 let msgs = vec![Message::user("Hello".to_string())];
996 let (system, rest) = extract_system_message(&msgs);
997 assert_eq!(system, None);
998 assert_eq!(rest.len(), 1);
999 }
1000
1001 #[test]
1002 fn filter_leading_assistant_removes_empty_assistant() {
1003 let msgs = vec![
1004 Message::assistant("Hi".to_string()),
1005 Message::user("Hello".to_string()),
1006 ];
1007 let filtered = filter_leading_assistant(&msgs);
1008 assert_eq!(filtered.len(), 1);
1009 assert_eq!(filtered[0].role, Role::User);
1010 }
1011
1012 #[test]
1013 fn filter_leading_assistant_keeps_assistant_with_tool_calls() {
1014 let msgs = vec![
1015 Message::assistant_with_tool_calls(
1016 "I'll use a tool".to_string(),
1017 vec![ToolCall {
1018 id: "call_1".to_string(),
1019 name: "test".to_string(),
1020 arguments: Value::Object(Default::default()),
1021 }],
1022 ),
1023 Message::user("Hello".to_string()),
1024 ];
1025 let filtered = filter_leading_assistant(&msgs);
1026 assert_eq!(filtered.len(), 2);
1028 }
1029
1030 #[test]
1031 fn parses_text_response() {
1032 let raw = r#"{
1033 "type": "message",
1034 "content": [{"type": "text", "text": "Hello"}],
1035 "stop_reason": "end_turn",
1036 "usage": {"input_tokens": 10, "output_tokens": 5}
1037 }"#;
1038 let parsed: AnthropicResponse = serde_json::from_str(raw).unwrap();
1039 let c = parse_completion(parsed);
1040 assert_eq!(c.content, "Hello");
1041 assert!(c.tool_calls.is_empty());
1042 assert_eq!(c.finish_reason, Some("stop".to_string()));
1043 assert!(c.usage.is_some());
1044 }
1045
1046 #[test]
1047 fn parses_tool_use_response() {
1048 let raw = r#"{
1049 "type": "message",
1050 "content": [
1051 {"type": "tool_use", "id": "call_1", "name": "read_file", "input": {"path": "src/lib.rs"}}
1052 ],
1053 "stop_reason": "tool_use",
1054 "usage": {"input_tokens": 100, "output_tokens": 50}
1055 }"#;
1056 let parsed: AnthropicResponse = serde_json::from_str(raw).unwrap();
1057 let c = parse_completion(parsed);
1058 assert!(c.content.is_empty());
1059 assert_eq!(c.tool_calls.len(), 1);
1060 assert_eq!(c.tool_calls[0].id, "call_1");
1061 assert_eq!(c.tool_calls[0].name, "read_file");
1062 assert_eq!(c.tool_calls[0].arguments["path"], "src/lib.rs");
1063 }
1064
1065 #[test]
1066 fn parses_mixed_content_response() {
1067 let raw = r#"{
1068 "type": "message",
1069 "content": [
1070 {"type": "text", "text": "I'll read that file. "},
1071 {"type": "tool_use", "id": "call_1", "name": "read_file", "input": {"path": "src/lib.rs"}}
1072 ],
1073 "stop_reason": "tool_use",
1074 "usage": {"input_tokens": 50, "output_tokens": 25}
1075 }"#;
1076 let parsed: AnthropicResponse = serde_json::from_str(raw).unwrap();
1077 let c = parse_completion(parsed);
1078 assert_eq!(c.content, "I'll read that file. ");
1079 assert_eq!(c.tool_calls.len(), 1);
1080 }
1081
1082 #[test]
1083 fn parses_usage_correctly() {
1084 let raw = r#"{
1085 "type": "message",
1086 "content": [{"type": "text", "text": "hi"}],
1087 "stop_reason": "end_turn",
1088 "usage": {
1089 "input_tokens": 100,
1090 "output_tokens": 50,
1091 "cache_creation_input_tokens": 20,
1092 "cache_read_input_tokens": 30
1093 }
1094 }"#;
1095 let parsed: AnthropicResponse = serde_json::from_str(raw).unwrap();
1096 let c = parse_completion(parsed);
1097 let u = c.usage.unwrap();
1098 assert_eq!(u.prompt_tokens, 100);
1099 assert_eq!(u.completion_tokens, 50);
1100 assert_eq!(u.cache_hit_tokens, 30);
1101 assert_eq!(u.cache_miss_tokens, 20);
1102 }
1103
1104 #[test]
1105 fn serializes_tool_result_message() {
1106 let msg = Message::tool_result("call_123".to_string(), "result content".to_string());
1107 let v = serialize_message(&msg);
1108 assert_eq!(v["role"], "user");
1109 let content = &v["content"][0];
1110 assert_eq!(content["type"], "tool_result");
1111 assert_eq!(content["tool_use_id"], "call_123");
1112 assert_eq!(content["content"], "result content");
1113 }
1114
1115 #[test]
1116 fn serializes_assistant_with_tool_calls() {
1117 let msg = Message::assistant_with_tool_calls(
1118 "I'll use a tool".to_string(),
1119 vec![ToolCall {
1120 id: "abc".to_string(),
1121 name: "write_file".to_string(),
1122 arguments: serde_json::json!({"path": "a", "contents": "b"}),
1123 }],
1124 );
1125 let v = serialize_message(&msg);
1126 assert_eq!(v["role"], "assistant");
1127 let content = &v["content"][0];
1128 assert_eq!(content["type"], "tool_use");
1129 assert_eq!(content["name"], "write_file");
1130 }
1131
1132 #[test]
1133 fn builds_request_with_system() {
1134 let req = build_request(
1135 "claude-3",
1136 0.2,
1137 4096,
1138 Some("You are helpful."),
1139 &[Message::user("Hi".to_string())],
1140 &[],
1141 );
1142 assert_eq!(req["system"], "You are helpful.");
1143 assert_eq!(req["model"], "claude-3");
1144 }
1145
1146 #[test]
1147 fn builds_request_without_system() {
1148 let req = build_request(
1149 "claude-3",
1150 0.2,
1151 4096,
1152 None,
1153 &[Message::user("Hi".to_string())],
1154 &[],
1155 );
1156 assert!(req.get("system").is_none());
1157 }
1158
1159 #[test]
1160 fn builds_request_with_tools() {
1161 let tools = vec![ToolSpec {
1162 name: "test".to_string(),
1163 description: "A test tool".to_string(),
1164 parameters: serde_json::json!({"type": "object"}),
1165 }];
1166 let req = build_request(
1167 "claude-3",
1168 0.2,
1169 4096,
1170 None,
1171 &[Message::user("Hi".to_string())],
1172 &tools,
1173 );
1174 assert!(req.get("tools").is_some());
1175 }
1176
1177 #[tokio::test]
1178 async fn test_a_mock_server_returns_canned_response() {
1179 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1181 let addr = listener.local_addr().unwrap();
1182
1183 let handle = std::thread::spawn(move || {
1184 let (mut stream, _) = listener.accept().unwrap();
1185 use std::io::{Read, Write};
1186 let mut buf = [0u8; 4096];
1187 let _ = stream.read(&mut buf);
1188
1189 let body = r#"{"type":"message","content":[{"type":"text","text":"Hello from Anthropic"}],"stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":5}}"#;
1190 let response = format!(
1191 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1192 body.len(),
1193 body
1194 );
1195 write!(stream, "{}", response).unwrap();
1196 stream.flush().unwrap();
1197 });
1198
1199 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1201
1202 let provider =
1203 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet");
1204 let result = provider
1205 .complete(&[Message::user("hi".to_string())], &[])
1206 .await;
1207
1208 let _ = handle.join();
1209
1210 let completion = result.expect("should succeed");
1211 assert!(completion.content.contains("Hello from Anthropic"));
1212 assert!(completion.usage.is_some());
1213 let u = completion.usage.unwrap();
1214 assert_eq!(u.prompt_tokens, 10);
1215 assert_eq!(u.completion_tokens, 5);
1216 }
1217
1218 #[tokio::test]
1219 async fn test_b_error_response_includes_model_name() {
1220 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1222 let addr = listener.local_addr().unwrap();
1223
1224 let handle = std::thread::spawn(move || {
1225 let (mut stream, _) = listener.accept().unwrap();
1226 use std::io::{Read, Write};
1227 let mut buf = [0u8; 4096];
1228 let _ = stream.read(&mut buf);
1229
1230 let body = r#"{"error":{"type":"authentication_error","message":"Invalid API Key"}}"#;
1231 let response = format!(
1232 "HTTP/1.1 401 Unauthorized\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1233 body.len(),
1234 body
1235 );
1236 write!(stream, "{}", response).unwrap();
1237 stream.flush().unwrap();
1238 });
1239
1240 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1241
1242 let provider =
1243 AnthropicProvider::new(format!("http://{addr}"), "sk-invalid", "claude-3-opus");
1244 let err = provider
1245 .complete(&[Message::user("hi".to_string())], &[])
1246 .await
1247 .unwrap_err();
1248
1249 let _ = handle.join();
1250
1251 let msg = err.to_string();
1252 assert!(
1253 msg.contains("claude-3-opus"),
1254 "error should contain model name: {msg}"
1255 );
1256 assert!(
1257 msg.contains("401"),
1258 "error should contain HTTP status: {msg}"
1259 );
1260 }
1261
1262 #[tokio::test]
1263 async fn test_c_tool_use_extracts_correct_tool_call() {
1264 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1266 let addr = listener.local_addr().unwrap();
1267
1268 let handle = std::thread::spawn(move || {
1269 let (mut stream, _) = listener.accept().unwrap();
1270 use std::io::{Read, Write};
1271 let mut buf = [0u8; 4096];
1272 let _ = stream.read(&mut buf);
1273
1274 let body = r#"{"type":"message","content":[{"type":"tool_use","id":"call_abc123","name":"read_file","input":{"path":"src/lib.rs"}}],"stop_reason":"tool_use","usage":{"input_tokens":50,"output_tokens":30}}"#;
1275 let response = format!(
1276 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1277 body.len(),
1278 body
1279 );
1280 write!(stream, "{}", response).unwrap();
1281 stream.flush().unwrap();
1282 });
1283
1284 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1285
1286 let provider =
1287 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet");
1288 let result = provider
1289 .complete(&[Message::user("read the file".to_string())], &[])
1290 .await;
1291
1292 let _ = handle.join();
1293
1294 let completion = result.expect("should succeed");
1295 assert_eq!(completion.tool_calls.len(), 1);
1296 let tc = &completion.tool_calls[0];
1297 assert_eq!(tc.id, "call_abc123");
1298 assert_eq!(tc.name, "read_file");
1299 assert_eq!(tc.arguments["path"], "src/lib.rs");
1300 }
1301
1302 #[tokio::test]
1303 async fn test_d_stream_request_includes_stream_true() {
1304 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1307 let addr = listener.local_addr().unwrap();
1308 let captured = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
1309 let captured_clone = captured.clone();
1310
1311 std::thread::spawn(move || {
1312 let (mut stream, _) = listener.accept().unwrap();
1313 use std::io::{Read, Write};
1314 let mut buf = [0u8; 8192];
1315 let n = stream.read(&mut buf).unwrap();
1316 let request = String::from_utf8_lossy(&buf[..n]).to_string();
1317 if let Some(body_start) = request.find("\r\n\r\n") {
1318 let body = request[body_start + 4..].trim().to_string();
1319 *captured_clone.lock().unwrap() = body.clone();
1320 }
1321 let body = "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
1323 write!(
1324 stream,
1325 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1326 body.len(),
1327 body,
1328 ).unwrap();
1329 stream.flush().unwrap();
1330 });
1331
1332 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1333
1334 let provider =
1335 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet");
1336 let _ = provider
1337 .stream(&[Message::user("hi".to_string())], &[], None)
1338 .await;
1339
1340 let body_str = captured.lock().unwrap().clone();
1341 let body: serde_json::Value = serde_json::from_str(&body_str).unwrap();
1342 assert_eq!(body["stream"], true);
1343 }
1344
1345 #[tokio::test]
1346 async fn test_e_stream_text_deltas_accumulate() {
1347 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1350 let addr = listener.local_addr().unwrap();
1351
1352 std::thread::spawn(move || {
1353 let (mut stream, _) = listener.accept().unwrap();
1354 use std::io::{Read, Write};
1355 let mut buf = [0u8; 4096];
1356 let _ = stream.read(&mut buf);
1357 let body = "\
1358event: message_start
1359data: {\"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}}}
1360
1361event: content_block_start
1362data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"Hello\"}}
1363
1364event: content_block_delta
1365data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" \"}}
1366
1367event: content_block_delta
1368data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"World\"}}
1369
1370event: message_delta
1371data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}
1372
1373event: message_stop
1374data: {\"type\":\"message_stop\"}
1375
1376";
1377 write!(
1378 stream,
1379 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1380 body.len(),
1381 body,
1382 ).unwrap();
1383 stream.flush().unwrap();
1384 });
1385
1386 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1387
1388 let provider =
1389 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet");
1390 let completion = provider
1391 .stream(&[Message::user("hi".to_string())], &[], None)
1392 .await
1393 .unwrap();
1394 assert_eq!(completion.content, "Hello World");
1395 assert_eq!(completion.finish_reason.as_deref(), Some("stop"));
1396 assert!(completion.usage.is_some());
1397 let u = completion.usage.unwrap();
1398 assert_eq!(u.prompt_tokens, 10);
1399 assert_eq!(u.completion_tokens, 5);
1400 }
1401
1402 #[tokio::test]
1403 async fn test_f_stream_tool_use_assembles_tool_calls() {
1404 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1407 let addr = listener.local_addr().unwrap();
1408
1409 std::thread::spawn(move || {
1410 let (mut stream, _) = listener.accept().unwrap();
1411 use std::io::{Read, Write};
1412 let mut buf = [0u8; 4096];
1413 let _ = stream.read(&mut buf);
1414 let body = "\
1415event: message_start
1416data: {\"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}}}
1417
1418event: content_block_start
1419data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_abc123\",\"name\":\"read_file\",\"input\":{}}}
1420
1421event: content_block_delta
1422data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\":\\\"src/\"}}
1423
1424event: content_block_delta
1425data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"lib.rs\\\"}\"}}
1426
1427event: message_delta
1428data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":30}}
1429
1430event: message_stop
1431data: {\"type\":\"message_stop\"}
1432
1433";
1434 write!(
1435 stream,
1436 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1437 body.len(),
1438 body,
1439 ).unwrap();
1440 stream.flush().unwrap();
1441 });
1442
1443 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1444
1445 let provider =
1446 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet");
1447 let completion = provider
1448 .stream(&[Message::user("read the file".to_string())], &[], None)
1449 .await
1450 .unwrap();
1451 assert_eq!(completion.tool_calls.len(), 1);
1452 let tc = &completion.tool_calls[0];
1453 assert_eq!(tc.id, "call_abc123");
1454 assert_eq!(tc.name, "read_file");
1455 assert_eq!(tc.arguments["path"], "src/lib.rs");
1456 assert_eq!(completion.finish_reason.as_deref(), Some("tool_calls"));
1457 }
1458
1459 #[tokio::test]
1460 async fn test_g_stream_tx_receives_text_chunks() {
1461 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1464 let addr = listener.local_addr().unwrap();
1465
1466 std::thread::spawn(move || {
1467 let (mut stream, _) = listener.accept().unwrap();
1468 use std::io::{Read, Write};
1469 let mut buf = [0u8; 4096];
1470 let _ = stream.read(&mut buf);
1471 let body = "\
1472event: message_start
1473data: {\"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}}}
1474
1475event: content_block_start
1476data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"A\"}}
1477
1478event: content_block_delta
1479data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"B\"}}
1480
1481event: content_block_delta
1482data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"C\"}}
1483
1484event: message_delta
1485data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":3}}
1486
1487event: message_stop
1488data: {\"type\":\"message_stop\"}
1489
1490";
1491 write!(
1492 stream,
1493 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1494 body.len(),
1495 body,
1496 ).unwrap();
1497 stream.flush().unwrap();
1498 });
1499
1500 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1501
1502 let provider =
1503 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet");
1504 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1505 let completion = provider
1506 .stream(&[Message::user("hi".to_string())], &[], Some(tx))
1507 .await
1508 .unwrap();
1509 assert_eq!(completion.content, "ABC");
1510
1511 let mut deltas = Vec::new();
1513 while let Some(d) = rx.recv().await {
1514 deltas.push(d);
1515 if deltas.len() >= 3 {
1516 break;
1517 }
1518 }
1519 assert_eq!(deltas, vec!["A", "B", "C"]);
1520 }
1521
1522 #[tokio::test]
1523 async fn test_h_stream_with_end_turn_produces_stop_reason() {
1524 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1527 let addr = listener.local_addr().unwrap();
1528
1529 std::thread::spawn(move || {
1530 let (mut stream, _) = listener.accept().unwrap();
1531 use std::io::{Read, Write};
1532 let mut buf = [0u8; 4096];
1533 let _ = stream.read(&mut buf);
1534 let body = "\
1535event: message_start
1536data: {\"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}}}
1537
1538event: content_block_start
1539data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"Hello\"}}
1540
1541event: message_delta
1542data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}
1543
1544event: message_stop
1545data: {\"type\":\"message_stop\"}
1546
1547";
1548 write!(
1549 stream,
1550 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1551 body.len(),
1552 body,
1553 ).unwrap();
1554 stream.flush().unwrap();
1555 });
1556
1557 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1558
1559 let provider =
1560 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet");
1561 let completion = provider
1562 .stream(&[Message::user("hi".to_string())], &[], None)
1563 .await
1564 .unwrap();
1565 assert_eq!(completion.content, "Hello");
1566 assert_eq!(completion.finish_reason.as_deref(), Some("stop"));
1567 assert!(completion.usage.is_some());
1568 }
1569
1570 #[test]
1571 fn policy_retries_5xx_with_exponential_backoff() {
1572 let policy = RetryPolicy::default();
1573 assert_eq!(
1574 policy.backoff_for(0, Some(503), false),
1575 Some(Duration::from_secs(1))
1576 );
1577 assert_eq!(
1578 policy.backoff_for(1, Some(500), false),
1579 Some(Duration::from_secs(2))
1580 );
1581 assert_eq!(policy.backoff_for(2, Some(500), false), None);
1582 }
1583
1584 #[test]
1585 fn policy_retries_network_errors() {
1586 let policy = RetryPolicy::default();
1587 assert_eq!(
1588 policy.backoff_for(0, None, true),
1589 Some(Duration::from_secs(1))
1590 );
1591 }
1592
1593 #[test]
1594 fn policy_does_not_retry_4xx() {
1595 let policy = RetryPolicy::default();
1596 assert_eq!(policy.backoff_for(0, Some(400), false), None);
1597 assert_eq!(policy.backoff_for(0, Some(401), false), None);
1598 assert_eq!(policy.backoff_for(0, Some(404), false), None);
1599 }
1600
1601 #[test]
1602 fn build_request_with_partition_marks_deferred_tools() {
1603 let eager = vec![(
1604 ToolSpec {
1605 name: "read_file".to_string(),
1606 description: "Read a file".to_string(),
1607 parameters: json!({"type": "object"}),
1608 },
1609 None,
1610 )];
1611 let deferred = vec![(
1612 ToolSpec {
1613 name: "notebook_edit".to_string(),
1614 description: "Edit a notebook".to_string(),
1615 parameters: json!({"type": "object"}),
1616 },
1617 Some("jupyter".to_string()),
1618 )];
1619 let body = build_request_with_partition(
1620 "claude-3",
1621 0.2,
1622 4096,
1623 None,
1624 &[Message::user("hi")],
1625 &eager,
1626 &deferred,
1627 None,
1628 );
1629
1630 let tools = body["tools"].as_array().expect("tools should be an array");
1631 assert_eq!(tools.len(), 2);
1632 assert!(tools[0].get("defer_loading").is_none());
1634 assert_eq!(tools[0]["name"], "read_file");
1635 assert_eq!(tools[1]["defer_loading"], true);
1637 assert_eq!(tools[1]["name"], "notebook_edit");
1638 }
1639
1640 #[test]
1641 fn build_request_with_partition_rewrites_pending_tool_result() {
1642 let eager: Vec<SpecWithHint> = Vec::new();
1643 let deferred: Vec<SpecWithHint> = Vec::new();
1644 let msgs = vec![
1645 Message::user("search for foo"),
1646 Message::assistant_with_tool_calls(
1648 String::new(),
1649 vec![ToolCall {
1650 id: "call_xyz".to_string(),
1651 name: TOOL_SEARCH_TOOL_NAME.to_string(),
1652 arguments: json!({"query": "foo"}),
1653 }],
1654 ),
1655 Message::tool_result("call_xyz".to_string(), String::new()),
1658 ];
1659 let names = ["notebook_edit".to_string()];
1660 let pending = Some(("call_xyz", &names[..]));
1661
1662 let body = build_request_with_partition(
1663 "claude-3", 0.2, 4096, None, &msgs, &eager, &deferred, pending,
1664 );
1665
1666 let wire_msgs = body["messages"].as_array().unwrap();
1667 assert_eq!(wire_msgs.len(), 3);
1668 let last = &wire_msgs[2];
1669 assert_eq!(last["role"], "user");
1670 let content = last["content"]
1671 .as_array()
1672 .expect("content should be an array");
1673 assert_eq!(content.len(), 1);
1674 assert_eq!(content[0]["type"], "tool_result");
1675 assert_eq!(content[0]["tool_use_id"], "call_xyz");
1676 let inner = content[0]["content"]
1677 .as_array()
1678 .expect("inner content must be array");
1679 assert_eq!(inner.len(), 1);
1680 assert_eq!(inner[0]["type"], "tool_reference");
1681 assert_eq!(inner[0]["tool_name"], "notebook_edit");
1682 }
1683
1684 #[test]
1685 fn tool_reference_array_emits_one_block_per_name() {
1686 let arr = tool_reference_array(&["a".to_string(), "b".to_string(), "c".to_string()]);
1687 let items = arr.as_array().unwrap();
1688 assert_eq!(items.len(), 3);
1689 for (i, expected) in ["a", "b", "c"].iter().enumerate() {
1690 assert_eq!(items[i]["type"], "tool_reference");
1691 assert_eq!(items[i]["tool_name"], *expected);
1692 }
1693 }
1694
1695 #[tokio::test]
1696 async fn complete_with_search_round_trips_tool_reference() {
1697 use std::io::{Read, Write};
1708 use std::sync::{Arc, Mutex};
1709
1710 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1711 let addr = listener.local_addr().unwrap();
1712 let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1713
1714 let captured_clone = captured.clone();
1715 std::thread::spawn(move || {
1716 for i in 0..2 {
1717 let (mut stream, _) = listener.accept().unwrap();
1718 let mut buf = [0u8; 16384];
1719 let mut acc = Vec::new();
1720 loop {
1723 let n = stream.read(&mut buf).unwrap();
1724 if n == 0 {
1725 break;
1726 }
1727 acc.extend_from_slice(&buf[..n]);
1728 if acc.windows(4).any(|w| w == b"\r\n\r\n") {
1729 break;
1730 }
1731 }
1732 let request = String::from_utf8_lossy(&acc).to_string();
1733 let body = request
1734 .split("\r\n\r\n")
1735 .nth(1)
1736 .unwrap_or("")
1737 .trim()
1738 .to_string();
1739 captured_clone.lock().unwrap().push(body);
1740
1741 let response_body = if i == 0 {
1742 r#"{"type":"message","content":[{"type":"tool_use","id":"call_search_1","name":"ToolSearchTool","input":{"query":"jupyter","max_results":3}}],"stop_reason":"tool_use","usage":{"input_tokens":50,"output_tokens":10}}"#
1745 } else {
1746 r#"{"type":"message","content":[{"type":"tool_use","id":"call_real_1","name":"notebook_edit","input":{"path":"analysis.ipynb"}}],"stop_reason":"tool_use","usage":{"input_tokens":80,"output_tokens":20}}"#
1749 };
1750 let response = format!(
1751 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1752 response_body.len(),
1753 response_body,
1754 );
1755 stream.write_all(response.as_bytes()).unwrap();
1756 stream.flush().unwrap();
1757 }
1758 });
1759
1760 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1761 let provider =
1762 AnthropicProvider::new(format!("http://{addr}"), "sk-noop", "claude-3-sonnet");
1763
1764 let eager = vec![(
1767 ToolSpec {
1768 name: "read_file".to_string(),
1769 description: "Read a UTF-8 text file under the workspace.".to_string(),
1770 parameters: json!({"type": "object"}),
1771 },
1772 None,
1773 )];
1774 let deferred = vec![(
1775 ToolSpec {
1776 name: "notebook_edit".to_string(),
1777 description: "Edit a Jupyter notebook cell.".to_string(),
1778 parameters: json!({"type": "object"}),
1779 },
1780 Some("jupyter notebook".to_string()),
1781 )];
1782
1783 let result = provider
1784 .complete_with_search(&[Message::user("find and read a file")], &eager, &deferred)
1785 .await;
1786
1787 let captured_bodies = captured.lock().unwrap().clone();
1788 assert_eq!(
1789 captured_bodies.len(),
1790 2,
1791 "expected two requests, got {}",
1792 captured_bodies.len()
1793 );
1794
1795 let body1: serde_json::Value = serde_json::from_str(&captured_bodies[0]).unwrap();
1798 let tools1 = body1["tools"].as_array().expect("tools should be array");
1799 let tool_names: Vec<&str> = tools1
1800 .iter()
1801 .map(|t| t["name"].as_str().unwrap_or(""))
1802 .collect();
1803 assert!(
1804 tool_names.contains(&"ToolSearchTool"),
1805 "missing ToolSearchTool in {:?}",
1806 tool_names
1807 );
1808 assert!(
1809 tool_names.contains(&"read_file"),
1810 "missing read_file in {:?}",
1811 tool_names
1812 );
1813 assert!(
1814 tool_names.contains(&"notebook_edit"),
1815 "missing notebook_edit in {:?}",
1816 tool_names
1817 );
1818 let notebook_def = tools1
1819 .iter()
1820 .find(|t| t["name"] == "notebook_edit")
1821 .unwrap();
1822 assert_eq!(
1823 notebook_def["defer_loading"], true,
1824 "notebook_edit should be deferred: {:?}",
1825 notebook_def
1826 );
1827
1828 let body2: serde_json::Value = serde_json::from_str(&captured_bodies[1]).unwrap();
1832 let msgs2 = body2["messages"].as_array().unwrap();
1833 assert!(
1834 msgs2.len() >= 3,
1835 "expected at least 3 messages (user, assistant, tool_result), got {}",
1836 msgs2.len()
1837 );
1838 let last = &msgs2[msgs2.len() - 1];
1840 assert_eq!(last["role"], "user");
1841 let content = last["content"].as_array().expect("content should be array");
1842 let tool_result = content
1843 .iter()
1844 .find(|c| c["type"] == "tool_result")
1845 .expect("expected a tool_result block");
1846 assert_eq!(tool_result["tool_use_id"], "call_search_1");
1847 let refs = tool_result["content"]
1848 .as_array()
1849 .expect("tool_result.content should be an array");
1850 assert!(!refs.is_empty(), "tool_reference array should be non-empty");
1851 for r in refs {
1852 assert_eq!(r["type"], "tool_reference");
1853 assert!(
1854 r["tool_name"].is_string(),
1855 "tool_reference.tool_name should be a string"
1856 );
1857 }
1858
1859 let completion = result.expect("should succeed");
1862 assert_eq!(completion.tool_calls.len(), 1);
1863 let tc = &completion.tool_calls[0];
1864 assert_eq!(tc.name, "notebook_edit");
1865 assert_eq!(tc.arguments["path"], "analysis.ipynb");
1866 }
1867}