Skip to main content

mecha_core/provider/
anthropic.rs

1//! Anthropic Messages API (`POST /v1/messages`).
2//!
3//! Raw HTTP: there is no official Anthropic SDK for Rust. Shapes follow the
4//! documented wire format for the Claude 5 family.
5//!
6//! Notes that bite if forgotten:
7//!   * `temperature` / `top_p` / `top_k` are rejected with a 400 on Opus 5 and
8//!     Fable 5. We never send them.
9//!   * `budget_tokens` is gone; thinking is `{"type": "adaptive"}`.
10//!   * On Opus 5 thinking is ON by default, and `{"type": "disabled"}` is only
11//!     accepted at effort `high` or below.
12//!   * `stop_reason: "refusal"` arrives as a 200. Check it before reading content.
13
14use 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        // Refused up front rather than silently dropped: someone who pinned the
37        // sampler for repeatable evals must not believe it is pinned here.
38        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                // Long turns are normal at high effort; the per-request cap is
50                // generous and streaming keeps the connection alive anyway.
51                .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    /// Send with the retry policy; on giving up, keep this provider's error
67    /// shape with the classification underneath for policy layers to match.
68    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        // A second, *moving* breakpoint on the last content block. The static
84        // one below pins tools+system; without this one the entire message
85        // history is re-sent uncached on every turn, and where it has been
86        // measured cache operations are ~80% of billed agent cost. The
87        // transcript is append-only between turns, so each request is a strict
88        // prefix of the next: this turn's cache write is next turn's cache
89        // read, which is exactly the trade the write premium is for. Never on
90        // a thinking block — the API rejects the marker there.
91        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        // Render order is tools -> system -> messages, so a cache breakpoint on
119        // the last system block covers the tool definitions too.
120        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                    // Only lands here when there's no system prompt to carry it.
133                    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            // Disabling thinking above `high` effort is a 400. Fail here with a
162            // message that says what to do rather than surfacing the API error.
163            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        // Retries cover the send and the status line — nothing has streamed
229        // yet. From here down a failure is mid-stream: deltas may already be
230        // on the user's screen, so it propagates as-is, carrying no
231        // `ProviderError` — which is also what tells the failover wrapper it
232        // must not re-issue the request.
233        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            // SSE frames are separated by a blank line. Split on bytes, not
242            // decoded text: a network chunk can end mid-character, and only a
243            // complete frame is guaranteed to be complete UTF-8.
244            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
264/// Pull the useful part out of an error body without assuming its shape.
265fn 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            // A thinking block without a signature can't be replayed; the API
290            // rejects reconstructed ones. Drop it rather than send it back.
291            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        // Server-side tool traffic and anything else we don't model yet.
332        _ => 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        // Non-streaming responses carry already-parsed JSON, so arguments
395        // cannot arrive malformed on this path.
396        malformed_tool_args: 0,
397    })
398}
399
400/// Reassembles a streamed message. Blocks arrive interleaved by index, and
401/// tool arguments arrive as partial JSON that only parses once complete.
402#[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                    // Input tokens — including both cache tiers — are known
437                    // from this first frame. Report them now so a run cancelled
438                    // mid-stream still knows what the prompt cost.
439                    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                    // An empty-argument call streams no partial_json at all.
568                    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                                // Don't kill the turn: hand the model an error
575                                // result so it can retry, and count it as the
576                                // reliability signal it is.
577                                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    /// Shared with `retry_tests` below.
613    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            // Fast retries: these tests measure counts and outcomes, not
620            // wall clock.
621            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    /// Anywhere in the tree, at any depth — a knob smuggled into a nested
665    /// object 400s exactly as loudly as one at the top level.
666    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        // The Claude 5 family rejects these outright, so the guarantee has to
679        // hold across every shape of request rather than the common one.
680        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        // Render order is tools -> system -> messages, so one marker on the
714        // last system block covers the tool definitions too. Prompt caching is
715        // a prefix match: a marker on the tools instead would leave the system
716        // prompt outside the cached span.
717        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        // Exactly one marker in the messages, on the very last block: the
746        // transcript is append-only between turns, so this turn's request is a
747        // prefix of the next and the whole history reads from cache. Two
748        // markers total (system + here) stays well under the API's limit of 4.
749        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        // The API rejects cache_control on thinking blocks. When one ends the
761        // transcript, the marker backs up to the nearest markable block.
762        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        // On Opus 5 the API rejects this combination. Failing here costs a
838        // function call; failing there costs a round trip and returns an error
839        // that does not say which knob to move.
840        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        // At `high` and below it is accepted, and must actually be sent.
858        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        // Signatures are opaque and checked. Sending a reconstructed one 400s
877        // the *next* turn, which is a confusing place to discover it.
878        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        // There is no legal slot for a user message between a `tool_use` and
896        // its result, so steering text has to travel as another block of the
897        // message already carrying the results.
898        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        // HTTP 200. Reading `content` without checking the stop reason gets you
917        // an empty string and no idea why.
918        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    /// A scripted HTTP server: each connection gets the next canned response
990    /// and is closed. Anything past the script gets a 500, which fails the
991    /// test through the assertion on the request count.
992    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                // Read the request head, then its content-length body, so the
1007                // client never sees a reset while still writing.
1008                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        // The assertion that matters: one request. A retried 401 is a lockout
1110        // risk, and elapsed-time assertions measure the scheduler.
1111        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        // The loop's compact-and-retry-once recovery keys on this.
1151        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        // Turn 1 asks for a tool; the request carrying its result dies with a
1161        // retryable 429; the retry succeeds. The tool must have run exactly
1162        // once — the retry re-sends the HTTP request, never the turn.
1163        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}