1use crate::config::ProviderConfig;
15use crate::message::*;
16use crate::provider::{Provider, StreamEvent, StreamSink};
17use anyhow::{anyhow, bail, Context, Result};
18use async_trait::async_trait;
19use futures::StreamExt;
20use serde_json::{json, Value};
21use std::collections::BTreeMap;
22
23const API_VERSION: &str = "2023-06-01";
24pub const DEFAULT_MODEL: &str = "claude-opus-5";
25
26pub struct Anthropic {
27 http: reqwest::Client,
28 api_key: String,
29 base_url: String,
30 default_model: String,
31 retry: crate::provider::retry::RetryPolicy,
32}
33
34impl Anthropic {
35 pub fn from_config(cfg: &ProviderConfig) -> Result<Self> {
36 if cfg.temperature.is_some() || cfg.seed.is_some() {
39 bail!(
40 "the Anthropic API rejects `temperature` and has no `seed`; remove them \
41 from this provider's config. Sampling cannot be pinned on this provider"
42 );
43 }
44 let api_key = cfg
45 .resolve_api_key()
46 .context("no Anthropic credentials found. Set ANTHROPIC_API_KEY, or put api_key_env / api_key in the provider config")?;
47 Ok(Self {
48 http: reqwest::Client::builder()
49 .timeout(std::time::Duration::from_secs(900))
52 .build()?,
53 api_key,
54 base_url: cfg
55 .base_url
56 .clone()
57 .unwrap_or_else(|| "https://api.anthropic.com".to_string()),
58 default_model: cfg
59 .model
60 .clone()
61 .unwrap_or_else(|| DEFAULT_MODEL.to_string()),
62 retry: crate::provider::retry::RetryPolicy::from_config(cfg),
63 })
64 }
65
66 async fn send(&self, body: &Value) -> Result<reqwest::Response> {
69 crate::provider::retry::send_with_retry(|| self.request(body), &self.retry)
70 .await
71 .map_err(|f| {
72 let message = match f.status {
73 Some(status) => format!("anthropic {status}: {}", api_error(&f.detail)),
74 None => format!("anthropic: {}", f.detail),
75 };
76 anyhow::Error::new(f.class).context(message)
77 })
78 }
79
80 fn body(&self, req: &CompletionRequest, stream: bool) -> Result<Value> {
81 let mut messages: Vec<Value> = req.messages.iter().map(encode_message).collect();
82
83 if req.cache_prompt {
92 let last_block = messages.iter_mut().rev().find_map(|m| {
93 m.get_mut("content")?
94 .as_array_mut()?
95 .iter_mut()
96 .rev()
97 .find(|b| b.get("type").and_then(Value::as_str) != Some("thinking"))
98 });
99 if let Some(block) = last_block {
100 block
101 .as_object_mut()
102 .unwrap()
103 .insert("cache_control".into(), json!({"type": "ephemeral"}));
104 }
105 }
106
107 let mut body = json!({
108 "model": req.model,
109 "max_tokens": req.max_tokens,
110 "messages": messages,
111 });
112 let obj = body.as_object_mut().unwrap();
113
114 if stream {
115 obj.insert("stream".into(), json!(true));
116 }
117
118 if !req.tools.is_empty() {
121 let last = req.tools.len() - 1;
122 let tools: Vec<Value> = req
123 .tools
124 .iter()
125 .enumerate()
126 .map(|(i, t)| {
127 let mut v = json!({
128 "name": t.name,
129 "description": t.description,
130 "input_schema": t.input_schema,
131 });
132 if req.cache_prompt && req.system.is_none() && i == last {
134 v.as_object_mut()
135 .unwrap()
136 .insert("cache_control".into(), json!({"type": "ephemeral"}));
137 }
138 v
139 })
140 .collect();
141 obj.insert("tools".into(), json!(tools));
142 }
143
144 if let Some(system) = &req.system {
145 let mut block = json!({"type": "text", "text": system});
146 if req.cache_prompt {
147 block
148 .as_object_mut()
149 .unwrap()
150 .insert("cache_control".into(), json!({"type": "ephemeral"}));
151 }
152 obj.insert("system".into(), json!([block]));
153 }
154
155 if req.thinking {
156 obj.insert(
157 "thinking".into(),
158 json!({"type": "adaptive", "display": "summarized"}),
159 );
160 } else {
161 if matches!(req.effort, Some(Effort::XHigh) | Some(Effort::Max)) {
164 bail!(
165 "thinking cannot be disabled at effort {}: lower effort to `high` or leave thinking on",
166 req.effort.unwrap().as_str()
167 );
168 }
169 obj.insert("thinking".into(), json!({"type": "disabled"}));
170 }
171
172 if let Some(effort) = req.effort {
173 obj.insert("output_config".into(), json!({"effort": effort.as_str()}));
174 }
175
176 Ok(body)
177 }
178
179 fn request(&self, body: &Value) -> reqwest::RequestBuilder {
180 self.http
181 .post(format!(
182 "{}/v1/messages",
183 self.base_url.trim_end_matches('/')
184 ))
185 .header("x-api-key", &self.api_key)
186 .header("anthropic-version", API_VERSION)
187 .header("content-type", "application/json")
188 .json(body)
189 }
190}
191
192#[async_trait]
193impl Provider for Anthropic {
194 fn id(&self) -> &str {
195 "anthropic"
196 }
197
198 fn default_model(&self) -> &str {
199 &self.default_model
200 }
201
202 async fn complete(
203 &self,
204 req: &CompletionRequest,
205 sink: Option<&StreamSink>,
206 ) -> Result<CompletionResponse> {
207 match sink {
208 Some(sink) => self.complete_streaming(req, sink).await,
209 None => self.complete_once(req).await,
210 }
211 }
212}
213
214impl Anthropic {
215 async fn complete_once(&self, req: &CompletionRequest) -> Result<CompletionResponse> {
216 let body = self.body(req, false)?;
217 let text = self.send(&body).await?.text().await?;
218 let v: Value = serde_json::from_str(&text).context("malformed response body")?;
219 decode_response(&v)
220 }
221
222 async fn complete_streaming(
223 &self,
224 req: &CompletionRequest,
225 sink: &StreamSink,
226 ) -> Result<CompletionResponse> {
227 let body = self.body(req, true)?;
228 let resp = self.send(&body).await?;
234
235 let mut acc = StreamAccumulator::default();
236 let mut buf = crate::provider::sse::SseBuffer::default();
237 let mut stream = resp.bytes_stream();
238
239 while let Some(chunk) = stream.next().await {
240 buf.push(&chunk?);
241 while let Some(frame) = buf.next_segment(b"\n\n") {
245 for line in frame.lines() {
246 let Some(data) = line.strip_prefix("data:") else {
247 continue;
248 };
249 let data = data.trim();
250 if data.is_empty() {
251 continue;
252 }
253 let event: Value =
254 serde_json::from_str(data).context("malformed SSE data frame")?;
255 acc.push(&event, sink)?;
256 }
257 }
258 }
259
260 acc.finish()
261 }
262}
263
264fn api_error(text: &str) -> String {
266 serde_json::from_str::<Value>(text)
267 .ok()
268 .and_then(|v| {
269 v.pointer("/error/message")
270 .and_then(Value::as_str)
271 .map(str::to_string)
272 })
273 .unwrap_or_else(|| text.chars().take(500).collect())
274}
275
276fn encode_message(m: &Message) -> Value {
277 let role = match m.role {
278 Role::User => "user",
279 Role::Assistant => "assistant",
280 };
281 let content: Vec<Value> = m.content.iter().filter_map(encode_block).collect();
282 json!({"role": role, "content": content})
283}
284
285fn encode_block(b: &Block) -> Option<Value> {
286 Some(match b {
287 Block::Text { text } => json!({"type": "text", "text": text}),
288 Block::Thinking { text, signature } => {
289 let sig = signature.as_ref()?;
292 json!({"type": "thinking", "thinking": text, "signature": sig})
293 }
294 Block::ToolUse { id, name, input } => {
295 json!({"type": "tool_use", "id": id, "name": name, "input": input})
296 }
297 Block::ToolResult {
298 tool_use_id,
299 content,
300 is_error,
301 } => json!({
302 "type": "tool_result",
303 "tool_use_id": tool_use_id,
304 "content": content,
305 "is_error": is_error,
306 }),
307 })
308}
309
310fn decode_block(v: &Value) -> Option<Block> {
311 match v.get("type")?.as_str()? {
312 "text" => Some(Block::Text {
313 text: v.get("text")?.as_str().unwrap_or_default().to_string(),
314 }),
315 "thinking" => Some(Block::Thinking {
316 text: v
317 .get("thinking")
318 .and_then(Value::as_str)
319 .unwrap_or_default()
320 .to_string(),
321 signature: v
322 .get("signature")
323 .and_then(Value::as_str)
324 .map(str::to_string),
325 }),
326 "tool_use" => Some(Block::ToolUse {
327 id: v.get("id")?.as_str()?.to_string(),
328 name: v.get("name")?.as_str()?.to_string(),
329 input: v.get("input").cloned().unwrap_or_else(|| json!({})),
330 }),
331 _ => None,
333 }
334}
335
336fn decode_stop_reason(s: Option<&str>) -> StopReason {
337 match s {
338 Some("end_turn") => StopReason::EndTurn,
339 Some("tool_use") => StopReason::ToolUse,
340 Some("max_tokens") => StopReason::MaxTokens,
341 Some("refusal") => StopReason::Refusal,
342 Some("pause_turn") => StopReason::PauseTurn,
343 _ => StopReason::Other,
344 }
345}
346
347fn decode_usage(v: Option<&Value>) -> Usage {
348 let Some(v) = v else { return Usage::default() };
349 let g = |k: &str| v.get(k).and_then(Value::as_u64).unwrap_or(0);
350 Usage {
351 input_tokens: g("input_tokens"),
352 output_tokens: g("output_tokens"),
353 cache_creation_input_tokens: g("cache_creation_input_tokens"),
354 cache_read_input_tokens: g("cache_read_input_tokens"),
355 }
356}
357
358fn decode_refusal(v: &Value) -> Option<Refusal> {
359 let d = v.get("stop_details")?;
360 if d.is_null() {
361 return None;
362 }
363 Some(Refusal {
364 category: d
365 .get("category")
366 .and_then(Value::as_str)
367 .map(str::to_string),
368 explanation: d
369 .get("explanation")
370 .and_then(Value::as_str)
371 .map(str::to_string),
372 })
373}
374
375fn decode_response(v: &Value) -> Result<CompletionResponse> {
376 let content = v
377 .get("content")
378 .and_then(Value::as_array)
379 .ok_or_else(|| anyhow!("response has no content array"))?
380 .iter()
381 .filter_map(decode_block)
382 .collect();
383
384 Ok(CompletionResponse {
385 message: Message::assistant(content),
386 stop_reason: decode_stop_reason(v.get("stop_reason").and_then(Value::as_str)),
387 usage: decode_usage(v.get("usage")),
388 refusal: decode_refusal(v),
389 model: v
390 .get("model")
391 .and_then(Value::as_str)
392 .unwrap_or_default()
393 .to_string(),
394 malformed_tool_args: 0,
397 })
398}
399
400#[derive(Default)]
403struct StreamAccumulator {
404 blocks: BTreeMap<usize, PartialBlock>,
405 stop_reason: Option<StopReason>,
406 usage: Usage,
407 refusal: Option<Refusal>,
408 model: String,
409}
410
411enum PartialBlock {
412 Text(String),
413 Thinking {
414 text: String,
415 signature: Option<String>,
416 },
417 ToolUse {
418 id: String,
419 name: String,
420 json: String,
421 },
422 Ignored,
423}
424
425impl StreamAccumulator {
426 fn push(&mut self, event: &Value, sink: &StreamSink) -> Result<()> {
427 match event.get("type").and_then(Value::as_str).unwrap_or("") {
428 "message_start" => {
429 if let Some(m) = event.get("message") {
430 self.model = m
431 .get("model")
432 .and_then(Value::as_str)
433 .unwrap_or_default()
434 .to_string();
435 self.usage.add(&decode_usage(m.get("usage")));
436 let _ = sink.send(StreamEvent::Usage(self.usage.clone()));
440 }
441 }
442 "content_block_start" => {
443 let idx = event.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
444 let cb = event.get("content_block").cloned().unwrap_or(Value::Null);
445 let partial = match cb.get("type").and_then(Value::as_str).unwrap_or("") {
446 "text" => PartialBlock::Text(
447 cb.get("text")
448 .and_then(Value::as_str)
449 .unwrap_or_default()
450 .to_string(),
451 ),
452 "thinking" => PartialBlock::Thinking {
453 text: cb
454 .get("thinking")
455 .and_then(Value::as_str)
456 .unwrap_or_default()
457 .to_string(),
458 signature: None,
459 },
460 "tool_use" => {
461 let name = cb
462 .get("name")
463 .and_then(Value::as_str)
464 .unwrap_or_default()
465 .to_string();
466 let _ = sink.send(StreamEvent::ToolUseStart { name: name.clone() });
467 PartialBlock::ToolUse {
468 id: cb
469 .get("id")
470 .and_then(Value::as_str)
471 .unwrap_or_default()
472 .to_string(),
473 name,
474 json: String::new(),
475 }
476 }
477 _ => PartialBlock::Ignored,
478 };
479 self.blocks.insert(idx, partial);
480 }
481 "content_block_delta" => {
482 let idx = event.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
483 let Some(delta) = event.get("delta") else {
484 return Ok(());
485 };
486 let Some(block) = self.blocks.get_mut(&idx) else {
487 return Ok(());
488 };
489 match (
490 delta.get("type").and_then(Value::as_str).unwrap_or(""),
491 block,
492 ) {
493 ("text_delta", PartialBlock::Text(buf)) => {
494 let t = delta
495 .get("text")
496 .and_then(Value::as_str)
497 .unwrap_or_default();
498 buf.push_str(t);
499 let _ = sink.send(StreamEvent::TextDelta(t.to_string()));
500 }
501 ("thinking_delta", PartialBlock::Thinking { text, .. }) => {
502 let t = delta
503 .get("thinking")
504 .and_then(Value::as_str)
505 .unwrap_or_default();
506 text.push_str(t);
507 let _ = sink.send(StreamEvent::ThinkingDelta(t.to_string()));
508 }
509 ("signature_delta", PartialBlock::Thinking { signature, .. }) => {
510 let s = delta
511 .get("signature")
512 .and_then(Value::as_str)
513 .unwrap_or_default();
514 signature.get_or_insert_with(String::new).push_str(s);
515 }
516 ("input_json_delta", PartialBlock::ToolUse { json, .. }) => {
517 json.push_str(
518 delta
519 .get("partial_json")
520 .and_then(Value::as_str)
521 .unwrap_or_default(),
522 );
523 }
524 _ => {}
525 }
526 }
527 "message_delta" => {
528 if let Some(d) = event.get("delta") {
529 if let Some(sr) = d.get("stop_reason").and_then(Value::as_str) {
530 self.stop_reason = Some(decode_stop_reason(Some(sr)));
531 }
532 if let Some(r) = decode_refusal(d) {
533 self.refusal = Some(r);
534 }
535 }
536 self.usage.add(&decode_usage(event.get("usage")));
537 let _ = sink.send(StreamEvent::Usage(self.usage.clone()));
538 }
539 "error" => {
540 bail!(
541 "anthropic stream error: {}",
542 event
543 .pointer("/error/message")
544 .and_then(Value::as_str)
545 .unwrap_or("unknown")
546 );
547 }
548 _ => {}
549 }
550 Ok(())
551 }
552
553 fn finish(self) -> Result<CompletionResponse> {
554 let mut content = Vec::new();
555 let mut malformed = 0u32;
556 for (_, block) in self.blocks {
557 match block {
558 PartialBlock::Text(text) => {
559 if !text.is_empty() {
560 content.push(Block::Text { text });
561 }
562 }
563 PartialBlock::Thinking { text, signature } => {
564 content.push(Block::Thinking { text, signature })
565 }
566 PartialBlock::ToolUse { id, name, json } => {
567 let input = if json.trim().is_empty() {
569 json!({})
570 } else {
571 match serde_json::from_str(&json) {
572 Ok(v) => v,
573 Err(e) => {
574 malformed += 1;
578 tracing::warn!(
579 tool = %name,
580 error = %e,
581 "tool arguments did not parse"
582 );
583 json!({"__malformed_arguments": json})
584 }
585 }
586 };
587 content.push(Block::ToolUse { id, name, input });
588 }
589 PartialBlock::Ignored => {}
590 }
591 }
592
593 Ok(CompletionResponse {
594 message: Message::assistant(content),
595 stop_reason: self.stop_reason.unwrap_or(StopReason::Other),
596 usage: self.usage,
597 refusal: self.refusal,
598 model: self.model,
599 malformed_tool_args: malformed,
600 })
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607
608 fn client() -> Anthropic {
609 client_at("http://localhost:1")
610 }
611
612 pub(super) fn client_at(base_url: &str) -> Anthropic {
614 Anthropic {
615 http: reqwest::Client::new(),
616 api_key: "test-key".into(),
617 base_url: base_url.into(),
618 default_model: DEFAULT_MODEL.into(),
619 retry: crate::provider::retry::RetryPolicy {
622 base_delay: std::time::Duration::from_millis(1),
623 ..Default::default()
624 },
625 }
626 }
627
628 fn req() -> CompletionRequest {
629 CompletionRequest {
630 model: DEFAULT_MODEL.into(),
631 system: None,
632 messages: vec![Message::user("hi")],
633 tools: Vec::new(),
634 max_tokens: 1024,
635 effort: None,
636 thinking: true,
637 cache_prompt: false,
638 }
639 }
640
641 fn spec(name: &str) -> ToolSpec {
642 ToolSpec {
643 name: name.into(),
644 description: "does a thing".into(),
645 input_schema: json!({"type": "object"}),
646 }
647 }
648
649 #[test]
650 fn a_configured_temperature_is_refused_at_construction() {
651 let cfg = crate::config::ProviderConfig {
652 kind: "anthropic".into(),
653 api_key: Some("test-key".into()),
654 temperature: Some(0.0),
655 ..Default::default()
656 };
657 let err = match Anthropic::from_config(&cfg) {
658 Err(e) => e.to_string(),
659 Ok(_) => panic!("a pinned temperature must not construct an Anthropic provider"),
660 };
661 assert!(err.contains("temperature"), "{err}");
662 }
663
664 fn mentions_key(v: &Value, key: &str) -> bool {
667 match v {
668 Value::Object(map) => {
669 map.contains_key(key) || map.values().any(|v| mentions_key(v, key))
670 }
671 Value::Array(items) => items.iter().any(|v| mentions_key(v, key)),
672 _ => false,
673 }
674 }
675
676 #[test]
677 fn the_sampling_knobs_are_never_sent_whatever_the_request_asks_for() {
678 for thinking in [true, false] {
681 for effort in [None, Some(Effort::Low), Some(Effort::High)] {
682 for cache_prompt in [true, false] {
683 let r = CompletionRequest {
684 system: Some("be brief".into()),
685 tools: vec![spec("fs_read")],
686 thinking,
687 effort,
688 cache_prompt,
689 ..req()
690 };
691 let body = client().body(&r, false).unwrap();
692 for knob in ["temperature", "top_p", "top_k", "budget_tokens"] {
693 assert!(
694 !mentions_key(&body, knob),
695 "{knob} was sent (thinking={thinking}, effort={effort:?})"
696 );
697 }
698 }
699 }
700 }
701 }
702
703 #[test]
704 fn the_cache_breakpoint_goes_on_the_last_system_block_and_nothing_after_it() {
705 let r = CompletionRequest {
706 system: Some("you are a harness".into()),
707 tools: vec![spec("fs_read"), spec("shell")],
708 cache_prompt: true,
709 ..req()
710 };
711 let body = client().body(&r, false).unwrap();
712
713 let system = body["system"].as_array().unwrap();
718 assert_eq!(system.len(), 1);
719 assert_eq!(system[0]["cache_control"], json!({"type": "ephemeral"}));
720
721 for tool in body["tools"].as_array().unwrap() {
722 assert!(
723 tool.get("cache_control").is_none(),
724 "a tool carried the breakpoint too"
725 );
726 }
727 }
728
729 #[test]
730 fn the_moving_breakpoint_sits_on_the_last_message_block_and_only_there() {
731 let r = CompletionRequest {
732 system: Some("you are a harness".into()),
733 tools: vec![spec("fs_read")],
734 cache_prompt: true,
735 messages: vec![
736 Message::user("first"),
737 Message::assistant(vec![Block::text("ok")]),
738 Message::user("second"),
739 ],
740 ..req()
741 };
742 let body = client().body(&r, false).unwrap();
743 let messages = body["messages"].as_array().unwrap();
744
745 assert!(!mentions_key(&messages[0], "cache_control"));
750 assert!(!mentions_key(&messages[1], "cache_control"));
751 let last = messages[2]["content"].as_array().unwrap();
752 assert_eq!(
753 last.last().unwrap()["cache_control"],
754 json!({"type": "ephemeral"})
755 );
756 }
757
758 #[test]
759 fn the_moving_breakpoint_never_lands_on_a_thinking_block() {
760 let r = CompletionRequest {
763 cache_prompt: true,
764 messages: vec![
765 Message::user("go"),
766 Message::assistant(vec![
767 Block::text("partial answer"),
768 Block::Thinking {
769 text: "hmm".into(),
770 signature: Some("sig".into()),
771 },
772 ]),
773 ],
774 ..req()
775 };
776 let body = client().body(&r, false).unwrap();
777 let blocks = body["messages"].as_array().unwrap()[1]["content"]
778 .as_array()
779 .unwrap();
780
781 assert_eq!(blocks[1]["type"], "thinking");
782 assert!(
783 blocks[1].get("cache_control").is_none(),
784 "marker on a thinking block 400s"
785 );
786 assert_eq!(blocks[0]["cache_control"], json!({"type": "ephemeral"}));
787 }
788
789 #[test]
790 fn with_no_system_prompt_the_breakpoint_falls_to_the_last_tool() {
791 let r = CompletionRequest {
792 system: None,
793 tools: vec![spec("fs_read"), spec("shell")],
794 cache_prompt: true,
795 ..req()
796 };
797 let body = client().body(&r, false).unwrap();
798 let tools = body["tools"].as_array().unwrap();
799
800 assert!(
801 tools[0].get("cache_control").is_none(),
802 "the breakpoint must be last, not first"
803 );
804 assert_eq!(tools[1]["cache_control"], json!({"type": "ephemeral"}));
805 }
806
807 #[test]
808 fn nothing_is_marked_cacheable_unless_it_was_asked_for() {
809 let r = CompletionRequest {
810 system: Some("you are a harness".into()),
811 tools: vec![spec("fs_read")],
812 cache_prompt: false,
813 ..req()
814 };
815 assert!(!mentions_key(
816 &client().body(&r, false).unwrap(),
817 "cache_control"
818 ));
819 }
820
821 #[test]
822 fn thinking_is_adaptive_rather_than_a_token_budget() {
823 let body = client()
824 .body(
825 &CompletionRequest {
826 thinking: true,
827 ..req()
828 },
829 false,
830 )
831 .unwrap();
832 assert_eq!(body["thinking"]["type"], "adaptive");
833 }
834
835 #[test]
836 fn disabling_thinking_above_high_effort_is_refused_before_the_request_is_sent() {
837 for effort in [Effort::XHigh, Effort::Max] {
841 let r = CompletionRequest {
842 thinking: false,
843 effort: Some(effort),
844 ..req()
845 };
846 let err = client().body(&r, false).unwrap_err().to_string();
847 assert!(
848 err.contains(effort.as_str()),
849 "the error should name the effort: {err}"
850 );
851 assert!(
852 err.contains("high"),
853 "the error should say what to do: {err}"
854 );
855 }
856
857 for effort in [
859 None,
860 Some(Effort::Low),
861 Some(Effort::Medium),
862 Some(Effort::High),
863 ] {
864 let r = CompletionRequest {
865 thinking: false,
866 effort,
867 ..req()
868 };
869 let body = client().body(&r, false).unwrap();
870 assert_eq!(body["thinking"], json!({"type": "disabled"}));
871 }
872 }
873
874 #[test]
875 fn a_thinking_block_with_no_signature_is_dropped_rather_than_replayed() {
876 let dropped = encode_block(&Block::Thinking {
879 text: "reasoning".into(),
880 signature: None,
881 });
882 assert!(dropped.is_none());
883
884 let kept = encode_block(&Block::Thinking {
885 text: "reasoning".into(),
886 signature: Some("sig-abc".into()),
887 })
888 .unwrap();
889 assert_eq!(kept["signature"], "sig-abc");
890 assert_eq!(kept["thinking"], "reasoning");
891 }
892
893 #[test]
894 fn tool_results_and_a_steer_ride_in_one_user_message() {
895 let encoded = encode_message(&Message::tool_results(vec![
899 Block::ToolResult {
900 tool_use_id: "t1".into(),
901 content: "42".into(),
902 is_error: false,
903 },
904 Block::text("actually, focus on X"),
905 ]));
906
907 assert_eq!(encoded["role"], "user");
908 let content = encoded["content"].as_array().unwrap();
909 assert_eq!(content.len(), 2);
910 assert_eq!(content[0]["type"], "tool_result");
911 assert_eq!(content[1]["type"], "text");
912 }
913
914 #[test]
915 fn a_refusal_arrives_as_an_ordinary_response_and_shows_up_in_the_stop_reason() {
916 let v = json!({
919 "content": [],
920 "model": DEFAULT_MODEL,
921 "stop_reason": "refusal",
922 "stop_details": {"category": "policy", "explanation": "declined"},
923 "usage": {"input_tokens": 12, "output_tokens": 0},
924 });
925
926 let resp = decode_response(&v).unwrap();
927 assert_eq!(resp.stop_reason, StopReason::Refusal);
928 let refusal = resp.refusal.expect("a refusal must carry its details");
929 assert_eq!(refusal.category.as_deref(), Some("policy"));
930 assert_eq!(resp.usage.input_tokens, 12);
931 }
932
933 #[test]
934 fn an_ordinary_response_carries_no_refusal() {
935 let v = json!({
936 "content": [{"type": "text", "text": "hello"}],
937 "model": DEFAULT_MODEL,
938 "stop_reason": "end_turn",
939 "stop_details": null,
940 "usage": {"input_tokens": 5, "output_tokens": 2},
941 });
942
943 let resp = decode_response(&v).unwrap();
944 assert_eq!(resp.stop_reason, StopReason::EndTurn);
945 assert!(resp.refusal.is_none());
946 assert_eq!(resp.message.text(), "hello");
947 }
948}
949
950#[cfg(test)]
951mod retry_tests {
952 use super::tests::client_at;
953 use super::*;
954 use crate::provider::retry::ProviderError;
955 use crate::provider::Provider;
956 use std::sync::atomic::{AtomicUsize, Ordering};
957 use std::sync::Arc;
958 use tokio::io::{AsyncReadExt, AsyncWriteExt};
959
960 fn req() -> CompletionRequest {
961 CompletionRequest {
962 model: DEFAULT_MODEL.into(),
963 system: None,
964 messages: vec![Message::user("hi")],
965 tools: Vec::new(),
966 max_tokens: 64,
967 effort: None,
968 thinking: false,
969 cache_prompt: false,
970 }
971 }
972
973 fn ok_body(text: &str) -> String {
974 serde_json::json!({
975 "content": [{"type": "text", "text": text}],
976 "stop_reason": "end_turn",
977 "usage": {"input_tokens": 1, "output_tokens": 1},
978 "model": DEFAULT_MODEL,
979 })
980 .to_string()
981 }
982
983 fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
984 haystack.windows(needle.len()).position(|w| w == needle)
985 }
986
987 type MockResponse = (u16, Vec<(&'static str, String)>, String);
988
989 async fn mock_http(responses: Vec<MockResponse>) -> (String, Arc<AtomicUsize>) {
993 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
994 let addr = listener.local_addr().unwrap();
995 let count = Arc::new(AtomicUsize::new(0));
996 let counter = Arc::clone(&count);
997
998 tokio::spawn(async move {
999 let mut responses = responses.into_iter();
1000 loop {
1001 let Ok((mut sock, _)) = listener.accept().await else {
1002 break;
1003 };
1004 counter.fetch_add(1, Ordering::SeqCst);
1005
1006 let mut buf = Vec::new();
1009 let mut tmp = [0u8; 8192];
1010 let (head_end, body_len) = loop {
1011 let n = sock.read(&mut tmp).await.unwrap_or(0);
1012 if n == 0 {
1013 break (buf.len(), 0);
1014 }
1015 buf.extend_from_slice(&tmp[..n]);
1016 if let Some(pos) = find(&buf, b"\r\n\r\n") {
1017 let head = String::from_utf8_lossy(&buf[..pos]).to_string();
1018 let len = head
1019 .lines()
1020 .find_map(|l| {
1021 let l = l.to_ascii_lowercase();
1022 l.strip_prefix("content-length:")
1023 .and_then(|v| v.trim().parse::<usize>().ok())
1024 })
1025 .unwrap_or(0);
1026 break (pos + 4, len);
1027 }
1028 };
1029 while buf.len() < head_end + body_len {
1030 let n = sock.read(&mut tmp).await.unwrap_or(0);
1031 if n == 0 {
1032 break;
1033 }
1034 buf.extend_from_slice(&tmp[..n]);
1035 }
1036
1037 let (status, headers, body) =
1038 responses
1039 .next()
1040 .unwrap_or((500, Vec::new(), "script exhausted".into()));
1041 let mut resp = format!(
1042 "HTTP/1.1 {status} R\r\ncontent-length: {}\r\nconnection: close\r\n",
1043 body.len()
1044 );
1045 for (k, v) in headers {
1046 resp.push_str(&format!("{k}: {v}\r\n"));
1047 }
1048 resp.push_str("\r\n");
1049 resp.push_str(&body);
1050 let _ = sock.write_all(resp.as_bytes()).await;
1051 let _ = sock.shutdown().await;
1052 }
1053 });
1054 (format!("http://{addr}"), count)
1055 }
1056
1057 #[tokio::test]
1058 async fn transient_failures_are_retried_until_the_request_succeeds() {
1059 let (url, count) = mock_http(vec![
1060 (
1061 429,
1062 vec![("retry-after", "0".into())],
1063 "rate limited".into(),
1064 ),
1065 (
1066 429,
1067 vec![("retry-after", "0".into())],
1068 "rate limited".into(),
1069 ),
1070 (200, vec![], ok_body("recovered")),
1071 ])
1072 .await;
1073
1074 let response = client_at(&url).complete(&req(), None).await.unwrap();
1075 assert_eq!(response.message.text(), "recovered");
1076 assert_eq!(count.load(Ordering::SeqCst), 3, "two retries, then success");
1077 }
1078
1079 #[tokio::test]
1080 async fn max_retries_zero_disables_retrying() {
1081 let (url, count) = mock_http(vec![
1082 (
1083 429,
1084 vec![("retry-after", "0".into())],
1085 "rate limited".into(),
1086 ),
1087 (200, vec![], ok_body("never reached")),
1088 ])
1089 .await;
1090
1091 let mut provider = client_at(&url);
1092 provider.retry.max_retries = 0;
1093 let err = provider.complete(&req(), None).await.unwrap_err();
1094 assert!(err.to_string().contains("429"), "{err:#}");
1095 assert_eq!(count.load(Ordering::SeqCst), 1);
1096 }
1097
1098 #[tokio::test]
1099 async fn auth_failures_are_terminal_by_request_count_not_by_elapsed_time() {
1100 let (url, count) = mock_http(vec![(401, vec![], "invalid x-api-key".into())]).await;
1101
1102 let err = client_at(&url).complete(&req(), None).await.unwrap_err();
1103 assert!(err.to_string().contains("401"), "{err:#}");
1104 assert_eq!(
1105 err.downcast_ref::<ProviderError>(),
1106 Some(&ProviderError::Auth),
1107 "the class rides under the message"
1108 );
1109 assert_eq!(count.load(Ordering::SeqCst), 1);
1112 }
1113
1114 #[tokio::test]
1115 async fn a_retry_after_past_the_cap_is_a_failure_not_a_nap() {
1116 let (url, count) = mock_http(vec![(
1117 429,
1118 vec![("retry-after", "3600".into())],
1119 "later".into(),
1120 )])
1121 .await;
1122
1123 let err = client_at(&url).complete(&req(), None).await.unwrap_err();
1124 assert!(matches!(
1125 err.downcast_ref::<ProviderError>(),
1126 Some(ProviderError::RateLimit { .. })
1127 ));
1128 assert_eq!(
1129 count.load(Ordering::SeqCst),
1130 1,
1131 "an hour-long wait must not be slept"
1132 );
1133 }
1134
1135 #[tokio::test]
1136 async fn context_overflow_stays_out_of_the_retry_path_and_reaches_compaction() {
1137 let (url, count) = mock_http(vec![(
1138 400,
1139 Vec::new(),
1140 r#"{"error":{"type":"exceed_context_size_error","message":"too big"}}"#.into(),
1141 )])
1142 .await;
1143
1144 let err = client_at(&url).complete(&req(), None).await.unwrap_err();
1145 assert_eq!(
1146 count.load(Ordering::SeqCst),
1147 1,
1148 "overflow retried with the same payload"
1149 );
1150 assert!(crate::agent::is_context_overflow(&err), "{err:#}");
1152 }
1153
1154 #[tokio::test]
1155 async fn a_mid_run_transient_error_never_re_executes_a_tool() {
1156 use crate::agent::{Agent, Conversation};
1157 use crate::config::{AgentConfig, PermissionMode};
1158 use crate::tool::{ModeApprover, Registry, Tool, ToolCtx, ToolOutput};
1159
1160 struct CountingTool(Arc<AtomicUsize>);
1164 #[async_trait]
1165 impl Tool for CountingTool {
1166 fn name(&self) -> &str {
1167 "echo"
1168 }
1169 fn description(&self) -> &str {
1170 "counts"
1171 }
1172 fn input_schema(&self) -> serde_json::Value {
1173 serde_json::json!({"type": "object"})
1174 }
1175 fn read_only(&self) -> bool {
1176 true
1177 }
1178 async fn call(&self, _input: serde_json::Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
1179 self.0.fetch_add(1, Ordering::SeqCst);
1180 Ok(ToolOutput::ok("ran"))
1181 }
1182 }
1183
1184 let tool_use_body = serde_json::json!({
1185 "content": [{"type": "tool_use", "id": "t1", "name": "echo", "input": {}}],
1186 "stop_reason": "tool_use",
1187 "usage": {"input_tokens": 1, "output_tokens": 1},
1188 "model": DEFAULT_MODEL,
1189 })
1190 .to_string();
1191 let (url, requests) = mock_http(vec![
1192 (200, vec![], tool_use_body),
1193 (
1194 429,
1195 vec![("retry-after", "0".into())],
1196 "rate limited".into(),
1197 ),
1198 (200, vec![], ok_body("done")),
1199 ])
1200 .await;
1201
1202 let executions = Arc::new(AtomicUsize::new(0));
1203 let mut registry = Registry::new();
1204 registry.insert(Arc::new(CountingTool(Arc::clone(&executions))));
1205 let agent = Agent::new(
1206 Box::new(client_at(&url)),
1207 registry,
1208 Arc::new(ModeApprover {
1209 mode: PermissionMode::Allow,
1210 }),
1211 ToolCtx {
1212 workspace: std::env::temp_dir(),
1213 ..Default::default()
1214 },
1215 AgentConfig {
1216 thinking: false,
1217 force_final_answer: false,
1218 ..Default::default()
1219 },
1220 None,
1221 )
1222 .unwrap();
1223
1224 let mut convo = Conversation::user("go");
1225 let outcome = agent.run(&mut convo, None).await.unwrap();
1226
1227 assert_eq!(outcome.text, "done");
1228 assert_eq!(
1229 requests.load(Ordering::SeqCst),
1230 3,
1231 "turn 2 was retried at the HTTP layer"
1232 );
1233 assert_eq!(
1234 executions.load(Ordering::SeqCst),
1235 1,
1236 "the retry duplicated a tool execution"
1237 );
1238 }
1239}