Skip to main content

myco_model/
anthropic.rs

1//! Anthropic Messages API backend.
2//!
3//! Ref: <https://platform.claude.com/docs/en/api/messages/create>
4//! Streaming: <https://platform.claude.com/docs/en/build-with-claude/streaming>
5//!
6//! Invariant: history thinking is never re-sent to the API; whether budget-mode
7//! thinking + tool use requires re-sending is an open question.
8
9use std::sync::Arc;
10
11use super::driver_core::{Slot, SlotMap, SseAccumulator};
12use super::*;
13
14/// Anthropic Messages API settings ([`BackendConfig::Anthropic`]).
15#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
16pub struct AnthropicBackendConfig {
17    pub anthropic_base_url: String,
18    pub anthropic_auth_token: String,
19    pub max_tokens_per_generate: usize,
20    pub debug_dump_api_requests: bool,
21    /// Maximum serialized request body, including history, images, tools, and prompts.
22    #[serde(default = "default_max_request_bytes")]
23    pub max_request_bytes: usize,
24    /// Transient-failure retry for this endpoint (`[gateways.NAME.retry]`).
25    #[serde(default)]
26    pub retry: RetryPolicy,
27    /// When set, enables Anthropic extended thinking at this effort level.
28    ///
29    /// The request shape follows the model's [`ThinkingMode`]: `adaptive` sends
30    /// `thinking.type: "adaptive"` plus `output_config.effort`; `budget` sends
31    /// `thinking.type: "enabled"` with a mapped `budget_tokens`; `none` sends
32    /// no thinking fields regardless of this value.
33    ///
34    /// Defaults to [`Effort::DEFAULT`] so thinking is always on for interactive use.
35    pub effort: Option<Effort>,
36}
37
38impl Default for AnthropicBackendConfig {
39    fn default() -> Self {
40        Self {
41            // No built-in gateway: the catalog (config.toml) supplies base_url.
42            anthropic_base_url: String::new(),
43            anthropic_auth_token: String::new(),
44            max_tokens_per_generate: 8192,
45            debug_dump_api_requests: false,
46            max_request_bytes: MAX_REQUEST_BYTES,
47            retry: RetryPolicy::default(),
48            effort: Some(Effort::DEFAULT),
49        }
50    }
51}
52
53/// Stateless Anthropic driver. Conversation history is owned by the caller.
54pub struct AnthropicGenerativeModel {
55    model: ModelSpec,
56    system_prompt: String,
57    tools: Vec<AnthropicTool>,
58    backend: AnthropicBackendConfig,
59    client: reqwest::Client,
60}
61
62impl AnthropicGenerativeModel {
63    pub fn new(
64        config: GenerativeModelConfig,
65        backend: AnthropicBackendConfig,
66    ) -> Result<Arc<Self>, ModelCreationError> {
67        // api.anthropic.com authenticates API keys (`sk-ant-…`) via the
68        // `x-api-key` header and rejects them as `Authorization: Bearer`;
69        // Bearer is the convention for gateway/OAuth tokens. Pick by token
70        // shape so both work against the default base URL.
71        let token = &backend.anthropic_auth_token;
72        let auth = if token.is_empty() {
73            None
74        } else if token.starts_with("sk-ant-") {
75            Some(("x-api-key", token.clone()))
76        } else {
77            Some(("authorization", format!("Bearer {token}")))
78        };
79        let client = driver_core::build_client(auth, &[("anthropic-version", "2023-06-01")])?;
80
81        let tools = config
82            .tools
83            .into_iter()
84            .map(|spec| AnthropicTool {
85                name: spec.name,
86                description: spec.description,
87                input_schema: spec.input_schema,
88            })
89            .collect();
90
91        Ok(Arc::new(Self {
92            model: config.model,
93            system_prompt: config.system_prompt,
94            tools,
95            backend,
96            client,
97        }))
98    }
99
100    /// Build (without sending) the streaming Messages request.
101    fn message_request(&self, messages: &[AnthropicMessage]) -> reqwest::RequestBuilder {
102        // Anthropic only honors `cache_control` on content blocks (system / messages /
103        // tools), never as a top-level request field. Put the breakpoint on the system
104        // prompt text block so the stable prefix can be cached across turns.
105        let system = if self.system_prompt.is_empty() {
106            None
107        } else {
108            Some(vec![AnthropicSystemText {
109                type_: "text",
110                text: &self.system_prompt,
111                cache_control: Some(AnthropicCacheControl::Ephemeral),
112            }])
113        };
114
115        let (thinking, output_config) =
116            thinking_request_fields(self.model.thinking, self.backend.effort);
117        // Anthropic requires max_tokens > thinking.budget_tokens for non-adaptive
118        // extended thinking (e.g. Haiku). Adaptive thinking has no budget field.
119        let mut max_tokens = self.backend.max_tokens_per_generate;
120        if let Some(AnthropicThinkingConfig::Enabled { budget_tokens }) = &thinking {
121            let need = (*budget_tokens as usize).saturating_add(1024);
122            if max_tokens <= *budget_tokens as usize {
123                max_tokens = need;
124            }
125        }
126        let request = AnthropicMessagesRequest {
127            max_tokens,
128            model: &self.model.api_id,
129            messages,
130            system,
131            tools: &self.tools,
132            stream: true,
133            thinking,
134            output_config,
135        };
136
137        self.client
138            .post(format!("{}/v1/messages", self.backend.anthropic_base_url))
139            .json(&request)
140    }
141}
142
143impl GenerativeModel for AnthropicGenerativeModel {
144    fn generate(&self, input: &[Message]) -> AsyncStream<GenerationEvent> {
145        let messages = match convert_messages(input) {
146            Ok(messages) => messages,
147            Err(e) => return driver_core::error_stream(e),
148        };
149        driver_core::spawn_generate(
150            self.message_request(&messages),
151            StreamAccumulator::default(),
152            "Anthropic",
153            self.backend.debug_dump_api_requests,
154            self.backend.max_request_bytes,
155        )
156    }
157}
158
159//
160// Message conversion
161//
162
163/// One role-alternating turn of Anthropic content — the merged form of one or
164/// more consecutive same-role [`Message`]s. A user turn may combine tool-result
165/// and text blocks, which no single `Message` variant can hold, so the merge
166/// yields these runs rather than `Message`s.
167struct MessageRun {
168    role: AnthropicRole,
169    content: Vec<AnthropicContent>,
170}
171
172/// Merge consecutive same-role turns into role-alternating runs. Anthropic
173/// requires alternating user/assistant roles, and tool-result blocks must lead
174/// the user turn they answer.
175///
176/// `wire_ids[i][j]` supplies the id for the `j`-th tool_use / tool_result of
177/// `input[i]` ([`wire_tool_ids`]); stored provider ids never reach the wire.
178fn merge_same_role_turns(input: &[Message], wire_ids: &[Vec<String>]) -> Box<[MessageRun]> {
179    let mut runs: Vec<MessageRun> = Vec::new();
180
181    for (i, message) in input.iter().enumerate() {
182        let (role, content): (_, Vec<AnthropicContent>) = match message {
183            Message::UserMessage { content } => (
184                AnthropicRole::User,
185                content.iter().cloned().filter_map(answer_block).collect(),
186            ),
187            Message::ToolResults { tool_use_results } => (
188                AnthropicRole::User,
189                tool_use_results
190                    .iter()
191                    .enumerate()
192                    .map(|(j, result)| AnthropicContent::ToolResult {
193                        tool_use_id: wire_ids[i][j].clone(),
194                        content: result
195                            .content
196                            .iter()
197                            .cloned()
198                            .filter_map(answer_block)
199                            .collect(),
200                        is_error: result.is_error,
201                        cache_control: None,
202                    })
203                    .collect(),
204            ),
205            Message::AssistantMessage {
206                content,
207                tool_uses,
208                turn_end_reason: _,
209            } => {
210                // Thinking may be stored in history for resume/UI; never echo it back to the API.
211                let mut blocks: Vec<AnthropicContent> =
212                    content.iter().cloned().filter_map(answer_block).collect();
213                for (j, tool_use) in tool_uses.iter().enumerate() {
214                    blocks.push(AnthropicContent::ToolUse {
215                        id: wire_ids[i][j].clone(),
216                        name: tool_use.name.clone(),
217                        input: tool_use.input.clone(),
218                        cache_control: None,
219                    });
220                }
221                // A thinking-only turn (e.g. max_tokens hit mid-thinking)
222                // strips to nothing; the API rejects empty assistant content
223                // on every later request, permanently wedging the session.
224                if blocks.is_empty() {
225                    continue;
226                }
227                (AnthropicRole::Assistant, blocks)
228            }
229        };
230
231        // Tool-result blocks must appear before any other content in a user turn.
232        if let Some(last) = runs.last_mut()
233            && last.role == role
234        {
235            if role == AnthropicRole::User {
236                let new_is_only_tool_results = !content.is_empty()
237                    && content
238                        .iter()
239                        .all(|c| matches!(c, AnthropicContent::ToolResult { .. }));
240                if new_is_only_tool_results {
241                    let mut combined = content;
242                    combined.append(&mut last.content);
243                    last.content = combined;
244                } else {
245                    last.content.extend(content);
246                }
247            } else {
248                last.content.extend(content);
249            }
250            continue;
251        }
252        runs.push(MessageRun { role, content });
253    }
254
255    runs.into_boxed_slice()
256}
257
258fn convert_messages(input: &[Message]) -> Result<Vec<AnthropicMessage>, GenerateError> {
259    // Merge into role-alternating runs, then emit one message per run — rolling
260    // cache breakpoints onto the final block of the last two. Marking a block
261    // caches the whole prefix up to it, and two breakpoints (rather than one)
262    // keep the previous turn's write inside Anthropic's 20-block lookback as the
263    // conversation grows — the recommended multi-turn pattern:
264    // <https://platform.claude.com/docs/en/build-with-claude/prompt-caching>
265    let wire_ids = wire_tool_ids(input)?;
266    let runs = merge_same_role_turns(input, &wire_ids);
267    let count = runs.len();
268    Ok(runs
269        .into_vec()
270        .into_iter()
271        .enumerate()
272        .map(|(i, MessageRun { role, mut content })| {
273            if i + 2 >= count
274                && let Some(last) = content.last_mut()
275            {
276                *last.cache_control_mut() = Some(AnthropicCacheControl::Ephemeral);
277            }
278            AnthropicMessage { role, content }
279        })
280        .collect())
281}
282
283//
284// Stream accumulation (the SSE drive loop is shared, in driver_core)
285//
286
287/// Maps Anthropic's unified content-block indices onto separate content/tool-use index spaces.
288#[derive(Default)]
289struct StreamAccumulator {
290    slots: SlotMap,
291    stop_reason: Option<AnthropicStopReason>,
292    finished: bool,
293}
294
295impl SseAccumulator for StreamAccumulator {
296    fn handle_data(&mut self, data: &str) -> Result<Vec<MessagePart>, GenerateError> {
297        let event: AnthropicStreamEvent = serde_json::from_str(data).map_err(|e| {
298            GenerateError::MalformedResponseError(format!(
299                "Failed to parse Anthropic SSE event JSON: {e}; data={data}"
300            ))
301        })?;
302        self.handle_event(event)
303    }
304
305    fn finished(&self) -> bool {
306        self.finished
307    }
308
309    fn finish(self) -> Result<(), GenerateError> {
310        driver_core::validate_finish("Anthropic", self.stop_reason.is_some(), std::iter::empty())
311    }
312}
313
314impl StreamAccumulator {
315    fn handle_event(
316        &mut self,
317        event: AnthropicStreamEvent,
318    ) -> Result<Vec<MessagePart>, GenerateError> {
319        let mut out = Vec::new();
320
321        match event {
322            AnthropicStreamEvent::MessageStart { message } => {
323                // Prompt-side counts (input + cache) arrive here; message_delta
324                // later carries only output_tokens. The accumulator merges both.
325                if let Some(u) = message.usage {
326                    out.push(MessagePart::Usage(u.into_token_usage()));
327                }
328            }
329            AnthropicStreamEvent::ContentBlockStart {
330                index,
331                content_block,
332            } => match content_block {
333                AnthropicStreamContentBlock::Text { text } => {
334                    let content_index = self.slots.open_content(index);
335                    out.push(MessagePart::ContentStart(ContentStart::Text {
336                        index: content_index,
337                    }));
338                    if !text.is_empty() {
339                        out.push(MessagePart::ContentDelta(ContentDelta::Text {
340                            index: content_index,
341                            delta: text,
342                        }));
343                    }
344                }
345                AnthropicStreamContentBlock::Thinking {
346                    thinking,
347                    signature,
348                } => {
349                    let content_index = self.slots.open_thinking(index);
350                    out.push(MessagePart::ContentStart(ContentStart::Thinking {
351                        index: content_index,
352                        signature,
353                        redacted: false,
354                    }));
355                    if !thinking.is_empty() {
356                        out.push(MessagePart::ContentDelta(ContentDelta::Thinking {
357                            index: content_index,
358                            delta: thinking,
359                        }));
360                    }
361                }
362                AnthropicStreamContentBlock::RedactedThinking { data } => {
363                    let content_index = self.slots.open_thinking(index);
364                    // Preserve opaque payload in signature; no plaintext deltas.
365                    out.push(MessagePart::ContentStart(ContentStart::Thinking {
366                        index: content_index,
367                        signature: if data.is_empty() { None } else { Some(data) },
368                        redacted: true,
369                    }));
370                }
371                AnthropicStreamContentBlock::ToolUse { name, input } => {
372                    // Input arrives via input_json_delta; starter object is usually empty.
373                    let _ = input;
374                    let tool_index = self.slots.open_tool_use(index);
375                    out.push(MessagePart::ToolUseStart(ToolUseStart {
376                        index: tool_index,
377                        name,
378                    }));
379                }
380                AnthropicStreamContentBlock::Other => {
381                    self.slots.ignore(index);
382                }
383            },
384            AnthropicStreamEvent::ContentBlockDelta { index, delta } => {
385                let slot = self.slots.get(index).ok_or_else(|| {
386                    GenerateError::MalformedResponseError(format!(
387                        "content_block_delta for unknown index {index}"
388                    ))
389                })?;
390
391                match (slot, delta) {
392                    (
393                        Slot::Content {
394                            index: content_index,
395                        }
396                        | Slot::Thinking {
397                            index: content_index,
398                        },
399                        AnthropicDelta::TextDelta { text },
400                    ) => {
401                        out.push(MessagePart::ContentDelta(ContentDelta::Text {
402                            index: content_index,
403                            delta: text,
404                        }));
405                    }
406                    (
407                        Slot::Content {
408                            index: content_index,
409                        }
410                        | Slot::Thinking {
411                            index: content_index,
412                        },
413                        AnthropicDelta::ThinkingDelta { thinking },
414                    ) => {
415                        out.push(MessagePart::ContentDelta(ContentDelta::Thinking {
416                            index: content_index,
417                            delta: thinking,
418                        }));
419                    }
420                    (
421                        Slot::Content {
422                            index: content_index,
423                        }
424                        | Slot::Thinking {
425                            index: content_index,
426                        },
427                        AnthropicDelta::InputJsonDelta { .. },
428                    ) => {
429                        return Err(GenerateError::MalformedResponseError(format!(
430                            "input_json_delta on content block index {content_index}"
431                        )));
432                    }
433                    (
434                        Slot::ToolUse { index: tool_index },
435                        AnthropicDelta::InputJsonDelta { partial_json },
436                    ) => {
437                        out.push(MessagePart::ToolUseDelta(ToolUseDelta {
438                            index: tool_index,
439                            input_json_delta: partial_json,
440                        }));
441                    }
442                    (
443                        Slot::ToolUse { .. },
444                        AnthropicDelta::TextDelta { .. } | AnthropicDelta::ThinkingDelta { .. },
445                    ) => {
446                        return Err(GenerateError::MalformedResponseError(
447                            "text/thinking delta on tool_use block".into(),
448                        ));
449                    }
450                    (Slot::Ignored, _) | (_, AnthropicDelta::Other) => {}
451                }
452            }
453            AnthropicStreamEvent::ContentBlockStop => {}
454            AnthropicStreamEvent::MessageDelta { delta, usage } => {
455                if let Some(u) = usage {
456                    out.push(MessagePart::Usage(u.into_token_usage()));
457                }
458                if let Some(stop_reason) = delta.stop_reason {
459                    if matches!(stop_reason, AnthropicStopReason::Refusal) {
460                        return Err(GenerateError::RefusalError(
461                            "Anthropic stop_reason=refusal".into(),
462                        ));
463                    }
464                    self.stop_reason = Some(stop_reason.clone());
465                    out.push(MessagePart::TurnEndReason(TurnEndReason::from(stop_reason)));
466                }
467            }
468            AnthropicStreamEvent::MessageStop => {
469                self.finished = true;
470            }
471            AnthropicStreamEvent::Ping => {}
472            AnthropicStreamEvent::Error { error } => {
473                return Err(GenerateError::ExecutionError(format!(
474                    "Anthropic stream error event: {error}"
475                )));
476            }
477            AnthropicStreamEvent::Other => {}
478        }
479
480        Ok(out)
481    }
482}
483
484//
485// Anthropic streaming event types
486//
487
488#[derive(Debug, serde::Deserialize)]
489#[serde(tag = "type")]
490enum AnthropicStreamEvent {
491    #[serde(rename = "message_start")]
492    MessageStart {
493        #[serde(default)]
494        message: AnthropicStartMessage,
495    },
496    #[serde(rename = "content_block_start")]
497    ContentBlockStart {
498        index: usize,
499        content_block: AnthropicStreamContentBlock,
500    },
501    #[serde(rename = "content_block_delta")]
502    ContentBlockDelta { index: usize, delta: AnthropicDelta },
503    #[serde(rename = "content_block_stop")]
504    ContentBlockStop,
505    #[serde(rename = "message_delta")]
506    MessageDelta {
507        delta: AnthropicMessageDelta,
508        #[serde(default)]
509        usage: Option<AnthropicUsage>,
510    },
511    #[serde(rename = "message_stop")]
512    MessageStop,
513    #[serde(rename = "ping")]
514    Ping,
515    #[serde(rename = "error")]
516    Error { error: serde_json::Value },
517    #[serde(other)]
518    Other,
519}
520
521#[derive(Debug, serde::Deserialize)]
522#[serde(tag = "type")]
523enum AnthropicStreamContentBlock {
524    #[serde(rename = "text")]
525    Text {
526        #[serde(default)]
527        text: String,
528    },
529    #[serde(rename = "thinking")]
530    Thinking {
531        #[serde(default)]
532        thinking: String,
533        #[serde(default)]
534        signature: Option<String>,
535    },
536    #[serde(rename = "redacted_thinking")]
537    RedactedThinking {
538        #[serde(default)]
539        data: String,
540    },
541    #[serde(rename = "tool_use")]
542    ToolUse {
543        name: String,
544        #[serde(default)]
545        input: serde_json::Value,
546    },
547    #[serde(other)]
548    Other,
549}
550
551#[derive(Debug, serde::Deserialize)]
552#[serde(tag = "type")]
553enum AnthropicDelta {
554    #[serde(rename = "text_delta")]
555    TextDelta { text: String },
556    #[serde(rename = "thinking_delta")]
557    ThinkingDelta { thinking: String },
558    #[serde(rename = "input_json_delta")]
559    InputJsonDelta { partial_json: String },
560    #[serde(other)]
561    Other,
562}
563
564#[derive(Debug, serde::Deserialize)]
565struct AnthropicMessageDelta {
566    stop_reason: Option<AnthropicStopReason>,
567}
568
569/// `message_start` payload; only its prompt-side `usage` is read.
570#[derive(Debug, Default, serde::Deserialize)]
571struct AnthropicStartMessage {
572    #[serde(default)]
573    usage: Option<AnthropicUsage>,
574}
575
576#[derive(Debug, Clone, serde::Deserialize)]
577struct AnthropicUsage {
578    #[serde(default)]
579    input_tokens: u64,
580    #[serde(default)]
581    output_tokens: u64,
582    #[serde(default)]
583    cache_read_input_tokens: Option<u64>,
584    #[serde(default)]
585    cache_creation_input_tokens: Option<u64>,
586}
587
588impl AnthropicUsage {
589    fn into_token_usage(self) -> crate::TokenUsage {
590        // Full prompt = input + cache read + cache write; cached_input = reads.
591        let cache_read = self.cache_read_input_tokens.unwrap_or(0);
592        let cache_creation = self.cache_creation_input_tokens.unwrap_or(0);
593        crate::TokenUsage {
594            input_tokens: self
595                .input_tokens
596                .saturating_add(cache_read)
597                .saturating_add(cache_creation),
598            output_tokens: self.output_tokens,
599            cached_input_tokens: cache_read,
600        }
601    }
602}
603
604//
605// Request / wire types
606//
607
608#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
609enum AnthropicRole {
610    #[serde(rename = "assistant")]
611    Assistant,
612    #[serde(rename = "user")]
613    User,
614}
615
616#[derive(Debug, Clone, serde::Serialize)]
617struct AnthropicMessage {
618    role: AnthropicRole,
619    content: Vec<AnthropicContent>,
620}
621
622/// One content block. Every kind carries an optional `cache_control` because
623/// Anthropic only accepts cache breakpoints on blocks — [`convert_messages`]
624/// sets it on the final block of a breakpoint message, and Anthropic then
625/// caches the whole prefix (tools + system + prior messages) up to and
626/// including that block.
627#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
628#[serde(tag = "type")]
629enum AnthropicContent {
630    #[serde(rename = "text")]
631    Text {
632        text: String,
633        #[serde(default, skip_serializing_if = "Option::is_none")]
634        cache_control: Option<AnthropicCacheControl>,
635    },
636
637    #[serde(rename = "image")]
638    Image {
639        source: AnthropicImageSource,
640        #[serde(default, skip_serializing_if = "Option::is_none")]
641        cache_control: Option<AnthropicCacheControl>,
642    },
643
644    #[serde(rename = "tool_use")]
645    ToolUse {
646        id: String,
647        name: String,
648        input: serde_json::Value,
649        #[serde(default, skip_serializing_if = "Option::is_none")]
650        cache_control: Option<AnthropicCacheControl>,
651    },
652
653    #[serde(rename = "tool_result")]
654    ToolResult {
655        tool_use_id: String,
656        content: Vec<AnthropicContent>,
657        is_error: bool,
658        #[serde(default, skip_serializing_if = "Option::is_none")]
659        cache_control: Option<AnthropicCacheControl>,
660    },
661}
662
663impl AnthropicContent {
664    /// The block's `cache_control` slot, whatever its kind.
665    fn cache_control_mut(&mut self) -> &mut Option<AnthropicCacheControl> {
666        match self {
667            AnthropicContent::Text { cache_control, .. }
668            | AnthropicContent::Image { cache_control, .. }
669            | AnthropicContent::ToolUse { cache_control, .. }
670            | AnthropicContent::ToolResult { cache_control, .. } => cache_control,
671        }
672    }
673}
674
675/// Wire form of Anthropic image `source`. Public `Content::Image.source` is an opaque
676/// string that we interpret as a URL, a `data:` URL, or raw base64 (default PNG).
677#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
678#[serde(tag = "type")]
679enum AnthropicImageSource {
680    #[serde(rename = "base64")]
681    Base64 { media_type: String, data: String },
682    #[serde(rename = "url")]
683    Url { url: String },
684}
685
686fn anthropic_image_source(source: String) -> AnthropicImageSource {
687    if source.starts_with("http://") || source.starts_with("https://") {
688        return AnthropicImageSource::Url { url: source };
689    }
690    if let Some(rest) = source.strip_prefix("data:") {
691        // data:[<media_type>][;base64],<data>
692        if let Some((meta, data)) = rest.split_once(',') {
693            let media_type = meta
694                .split(';')
695                .next()
696                .filter(|s| !s.is_empty())
697                .unwrap_or("image/png")
698                .to_string();
699            return AnthropicImageSource::Base64 {
700                media_type,
701                data: data.to_string(),
702            };
703        }
704    }
705    AnthropicImageSource::Base64 {
706        media_type: "image/png".into(),
707        data: source,
708    }
709}
710
711/// Wire block for one history content item. History thinking is never re-sent
712/// (module invariant), so `Content::Thinking` yields `None`.
713fn answer_block(content: Content) -> Option<AnthropicContent> {
714    match content {
715        Content::Text { text } | Content::System { text, .. } => Some(AnthropicContent::Text {
716            text,
717            cache_control: None,
718        }),
719        Content::Image { source } => Some(AnthropicContent::Image {
720            source: anthropic_image_source(source),
721            cache_control: None,
722        }),
723        Content::Thinking { .. } => None,
724    }
725}
726
727#[derive(Debug, serde::Serialize)]
728struct AnthropicMessagesRequest<'a> {
729    max_tokens: usize,
730    messages: &'a [AnthropicMessage],
731    model: &'a str,
732    #[serde(skip_serializing_if = "Option::is_none")]
733    system: Option<Vec<AnthropicSystemText<'a>>>,
734    tools: &'a [AnthropicTool],
735    stream: bool,
736    #[serde(skip_serializing_if = "Option::is_none")]
737    thinking: Option<AnthropicThinkingConfig>,
738    #[serde(skip_serializing_if = "Option::is_none")]
739    output_config: Option<AnthropicOutputConfig>,
740}
741
742/// Wire form of Anthropic `thinking` request field.
743///
744/// Newer models reject `type: "enabled"` and require adaptive thinking plus
745/// `output_config.effort`. Older models require `type: "enabled"` with a budget.
746#[derive(Debug, serde::Serialize, Clone, PartialEq, Eq)]
747#[serde(tag = "type")]
748enum AnthropicThinkingConfig {
749    #[serde(rename = "enabled")]
750    Enabled { budget_tokens: u32 },
751    #[serde(rename = "adaptive")]
752    Adaptive {
753        /// `"summarized"` surfaces readable thinking text; default on newest models is
754        /// `"omitted"` (empty `thinking` field).
755        #[serde(skip_serializing_if = "Option::is_none")]
756        display: Option<&'static str>,
757    },
758}
759
760#[derive(Debug, serde::Serialize, Clone, PartialEq, Eq)]
761struct AnthropicOutputConfig {
762    #[serde(skip_serializing_if = "Option::is_none")]
763    effort: Option<&'static str>,
764}
765
766/// Build `thinking` / `output_config` for the given model when effort is set.
767fn thinking_request_fields(
768    mode: ThinkingMode,
769    effort: Option<Effort>,
770) -> (
771    Option<AnthropicThinkingConfig>,
772    Option<AnthropicOutputConfig>,
773) {
774    let Some(effort) = effort else {
775        return (None, None);
776    };
777
778    match mode {
779        ThinkingMode::Adaptive => (
780            Some(AnthropicThinkingConfig::Adaptive {
781                // Agent UIs stream thinking; omit would yield empty thinking deltas.
782                display: Some("summarized"),
783            }),
784            Some(AnthropicOutputConfig {
785                effort: Some(effort.as_str()),
786            }),
787        ),
788        ThinkingMode::Budget => (
789            Some(AnthropicThinkingConfig::Enabled {
790                budget_tokens: effort.budget_tokens(),
791            }),
792            None,
793        ),
794        // `effort` is rejected for this protocol at catalog resolution.
795        ThinkingMode::Effort | ThinkingMode::None => (None, None),
796    }
797}
798
799/// System prompt as a content-block array so `cache_control` can be attached.
800#[derive(Debug, serde::Serialize)]
801struct AnthropicSystemText<'a> {
802    #[serde(rename = "type")]
803    type_: &'static str,
804    text: &'a str,
805    #[serde(skip_serializing_if = "Option::is_none")]
806    cache_control: Option<AnthropicCacheControl>,
807}
808
809#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, Copy)]
810#[serde(tag = "type")]
811enum AnthropicCacheControl {
812    #[serde(rename = "ephemeral")]
813    Ephemeral,
814}
815
816#[derive(Debug, Clone, serde::Serialize)]
817struct AnthropicTool {
818    name: String,
819    description: String,
820    input_schema: serde_json::Value,
821}
822
823#[derive(Clone, Debug, serde::Deserialize)]
824enum AnthropicStopReason {
825    #[serde(rename = "end_turn")]
826    EndTurn,
827    #[serde(rename = "max_tokens")]
828    MaxTokens,
829    #[serde(rename = "stop_sequence")]
830    StopSequence,
831    #[serde(rename = "tool_use")]
832    ToolUse,
833    #[serde(rename = "pause_turn")]
834    PauseTurn,
835    #[serde(rename = "refusal")]
836    Refusal,
837    /// The API grows stop reasons over time (`model_context_window_exceeded`
838    /// arrived in 2025); an unknown one must not fail the whole message_delta
839    /// event and discard an already-streamed generation.
840    #[serde(other)]
841    Unknown,
842}
843
844impl From<AnthropicStopReason> for TurnEndReason {
845    fn from(stop_reason: AnthropicStopReason) -> Self {
846        match stop_reason {
847            AnthropicStopReason::EndTurn => TurnEndReason::EndTurn,
848            AnthropicStopReason::MaxTokens => TurnEndReason::MaxTokens,
849            AnthropicStopReason::ToolUse => TurnEndReason::ToolUse,
850            AnthropicStopReason::StopSequence => {
851                TurnEndReason::Other("Anthropic::StopSequence".into())
852            }
853            AnthropicStopReason::PauseTurn => TurnEndReason::Other("Anthropic::PauseTurn".into()),
854            AnthropicStopReason::Refusal => TurnEndReason::Other("Anthropic::Refusal".into()),
855            AnthropicStopReason::Unknown => TurnEndReason::Other("Anthropic::Unknown".into()),
856        }
857    }
858}
859
860//
861// Tests
862//
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867    use crate::test_support::{
868        assistant, assistant_tool, expect_text_delta, expect_thinking_delta,
869        expect_tool_args_delta, expect_tool_start, tool_results, user,
870    };
871
872    fn is_legal_anthropic_id(id: &str) -> bool {
873        !id.is_empty()
874            && id
875                .chars()
876                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
877    }
878
879    #[test]
880    fn system_parts_reach_the_provider_in_text_and_image_messages_without_metadata() {
881        for with_image in [false, true] {
882            let mut content = vec![Content::System {
883                kind: "private_kind".into(),
884                text: "runtime notice".into(),
885                data: serde_json::json!({"private_metadata":true}),
886            }];
887            if with_image {
888                content.push(Content::Image {
889                    source: "data:image/png;base64,AAAA".into(),
890                });
891            }
892            content.push(Content::Text {
893                text: "human request".into(),
894            });
895            let input = [Message::UserMessage { content }];
896            let wire = serde_json::to_string(&convert_messages(&input).unwrap()).unwrap();
897            assert!(wire.contains("runtime notice"), "{wire}");
898            assert!(wire.contains("human request"), "{wire}");
899            assert!(!wire.contains("private_kind"), "{wire}");
900            assert!(!wire.contains("private_metadata"), "{wire}");
901        }
902    }
903
904    #[test]
905    fn test_role_serdes() {
906        let role = AnthropicRole::Assistant;
907        let json = serde_json::to_string(&role).unwrap();
908        assert_eq!(json, r#""assistant""#);
909    }
910
911    #[test]
912    fn test_content_serdes() {
913        let content = AnthropicContent::Text {
914            text: "Hello, world".to_string(),
915            cache_control: None,
916        };
917        let json = serde_json::to_string(&content).unwrap();
918        assert_eq!(json, r#"{"type":"text","text":"Hello, world"}"#);
919    }
920
921    #[test]
922    fn cache_breakpoints_mark_last_two_messages() {
923        let input = [user("one"), assistant("two"), user("three")];
924        let json = serde_json::to_value(convert_messages(&input).unwrap()).unwrap();
925        // The final two messages carry a breakpoint on their last block.
926        assert_eq!(json[2]["content"][0]["cache_control"]["type"], "ephemeral");
927        assert_eq!(json[1]["content"][0]["cache_control"]["type"], "ephemeral");
928        // The oldest message does not.
929        assert!(json[0]["content"][0].get("cache_control").is_none());
930    }
931
932    #[test]
933    fn cache_breakpoint_marks_only_final_block_not_nested() {
934        let input = [
935            Message::AssistantMessage {
936                content: vec![],
937                tool_uses: vec![
938                    ToolUse {
939                        name: "x".into(),
940                        input: serde_json::Value::Null,
941                    },
942                    ToolUse {
943                        name: "x".into(),
944                        input: serde_json::Value::Null,
945                    },
946                ],
947                turn_end_reason: None,
948            },
949            Message::ToolResults {
950                tool_use_results: vec![
951                    ToolResult {
952                        content: vec![Content::Text { text: "ra".into() }],
953                        is_error: false,
954                        status: None,
955                    },
956                    ToolResult {
957                        content: vec![Content::Text { text: "rb".into() }],
958                        is_error: false,
959                        status: None,
960                    },
961                ],
962            },
963        ];
964        let json = serde_json::to_value(convert_messages(&input).unwrap()).unwrap();
965        // Last message has two tool_result blocks; only the final one is marked.
966        assert_eq!(json[1]["content"][1]["cache_control"]["type"], "ephemeral");
967        assert!(json[1]["content"][0].get("cache_control").is_none());
968        // The breakpoint sits on the block, not spliced into its nested body.
969        assert!(
970            json[1]["content"][1]["content"][0]
971                .get("cache_control")
972                .is_none()
973        );
974    }
975
976    #[test]
977    fn convert_messages_empty_is_noop() {
978        assert!(convert_messages(&[]).unwrap().is_empty());
979    }
980
981    /// An image inside a tool result (e.g. `view_image`) must reach the API as
982    /// a nested image block, not be dropped or stringified.
983    #[test]
984    fn tool_result_image_serializes_as_nested_image_block() {
985        let input = [
986            assistant_tool(None, "view_image", serde_json::json!({})),
987            Message::ToolResults {
988                tool_use_results: vec![ToolResult {
989                    content: vec![Content::Image {
990                        source: "data:image/png;base64,AAAA".into(),
991                    }],
992                    is_error: false,
993                    status: None,
994                }],
995            },
996        ];
997        let json = serde_json::to_value(convert_messages(&input).unwrap()).unwrap();
998        assert_eq!(json[1]["role"], "user");
999        assert_eq!(json[1]["content"][0]["type"], "tool_result");
1000        assert_eq!(json[1]["content"][0]["content"][0]["type"], "image");
1001        assert_eq!(
1002            json[1]["content"][0]["content"][0]["source"]["type"],
1003            "base64"
1004        );
1005        assert_eq!(
1006            json[1]["content"][0]["content"][0]["source"]["media_type"],
1007            "image/png"
1008        );
1009        assert_eq!(
1010            json[1]["content"][0]["content"][0]["source"]["data"],
1011            "AAAA"
1012        );
1013    }
1014
1015    #[test]
1016    fn image_source_url_and_base64_wire_format() {
1017        let url = answer_block(Content::Image {
1018            source: "https://example.com/a.png".into(),
1019        })
1020        .unwrap();
1021        let url_json = serde_json::to_value(&url).unwrap();
1022        assert_eq!(url_json["type"], "image");
1023        assert_eq!(url_json["source"]["type"], "url");
1024        assert_eq!(url_json["source"]["url"], "https://example.com/a.png");
1025
1026        let b64 = answer_block(Content::Image {
1027            source: "iVBORw0KGgo=".into(),
1028        })
1029        .unwrap();
1030        let b64_json = serde_json::to_value(&b64).unwrap();
1031        assert_eq!(b64_json["source"]["type"], "base64");
1032        assert_eq!(b64_json["source"]["media_type"], "image/png");
1033        assert_eq!(b64_json["source"]["data"], "iVBORw0KGgo=");
1034
1035        let data_url = answer_block(Content::Image {
1036            source: "data:image/jpeg;base64,/9j/4AAQ".into(),
1037        })
1038        .unwrap();
1039        let data_json = serde_json::to_value(&data_url).unwrap();
1040        assert_eq!(data_json["source"]["type"], "base64");
1041        assert_eq!(data_json["source"]["media_type"], "image/jpeg");
1042        assert_eq!(data_json["source"]["data"], "/9j/4AAQ");
1043    }
1044
1045    #[test]
1046    fn request_puts_cache_control_on_system_block_not_root() {
1047        let system = vec![AnthropicSystemText {
1048            type_: "text",
1049            text: "You are helpful.",
1050            cache_control: Some(AnthropicCacheControl::Ephemeral),
1051        }];
1052        let request = AnthropicMessagesRequest {
1053            max_tokens: 128,
1054            model: "claude-haiku-4-5",
1055            messages: &[],
1056            system: Some(system),
1057            tools: &[],
1058            stream: true,
1059            thinking: None,
1060            output_config: None,
1061        };
1062        let json = serde_json::to_value(&request).unwrap();
1063        assert!(json.get("cache_control").is_none());
1064        assert_eq!(json["system"][0]["type"], "text");
1065        assert_eq!(json["system"][0]["text"], "You are helpful.");
1066        assert_eq!(json["system"][0]["cache_control"]["type"], "ephemeral");
1067        assert!(json["system"][0]["cache_control"].get("ttl").is_none());
1068    }
1069
1070    #[test]
1071    fn request_omits_system_when_empty() {
1072        let request = AnthropicMessagesRequest {
1073            max_tokens: 128,
1074            model: "claude-haiku-4-5",
1075            messages: &[],
1076            system: None,
1077            tools: &[],
1078            stream: true,
1079            thinking: None,
1080            output_config: None,
1081        };
1082        let json = serde_json::to_value(&request).unwrap();
1083        assert!(json.get("system").is_none());
1084    }
1085
1086    #[test]
1087    fn adaptive_thinking_uses_effort_not_budget() {
1088        let (thinking, output_config) =
1089            thinking_request_fields(ThinkingMode::Adaptive, Some(Effort::High));
1090        let request = AnthropicMessagesRequest {
1091            max_tokens: 128,
1092            model: "claude-opus-4-8",
1093            messages: &[],
1094            system: None,
1095            tools: &[],
1096            stream: true,
1097            thinking,
1098            output_config,
1099        };
1100        let json = serde_json::to_value(&request).unwrap();
1101        assert_eq!(json["thinking"]["type"], "adaptive");
1102        assert_eq!(json["thinking"]["display"], "summarized");
1103        assert!(json["thinking"].get("budget_tokens").is_none());
1104        assert_eq!(json["output_config"]["effort"], "high");
1105    }
1106
1107    #[test]
1108    fn manual_thinking_uses_budget_tokens() {
1109        let (thinking, output_config) =
1110            thinking_request_fields(ThinkingMode::Budget, Some(Effort::Medium));
1111        let request = AnthropicMessagesRequest {
1112            max_tokens: 128,
1113            model: "claude-haiku-4-5",
1114            messages: &[],
1115            system: None,
1116            tools: &[],
1117            stream: true,
1118            thinking,
1119            output_config,
1120        };
1121        let json = serde_json::to_value(&request).unwrap();
1122        assert_eq!(json["thinking"]["type"], "enabled");
1123        assert_eq!(
1124            json["thinking"]["budget_tokens"],
1125            Effort::Medium.budget_tokens()
1126        );
1127        assert!(json.get("output_config").is_none());
1128    }
1129
1130    #[test]
1131    fn effort_budget_token_mapping() {
1132        assert_eq!(Effort::Low.budget_tokens(), 1_024);
1133        assert_eq!(Effort::Medium.budget_tokens(), 4_096);
1134        assert_eq!(Effort::High.budget_tokens(), 16_000);
1135        assert_eq!(Effort::Max.budget_tokens(), 64_000);
1136    }
1137
1138    #[test]
1139    fn max_tokens_raised_above_enabled_thinking_budget() {
1140        // Default backend max_tokens is 8192; High budget is 16000 — request builder
1141        // must raise max_tokens (validated by reimplementing the clamp here).
1142        let budget = Effort::High.budget_tokens() as usize;
1143        let configured = 8192usize;
1144        let max_tokens = if configured <= budget {
1145            budget.saturating_add(1024)
1146        } else {
1147            configured
1148        };
1149        assert!(max_tokens > budget);
1150        assert_eq!(max_tokens, 16_000 + 1024);
1151    }
1152
1153    #[test]
1154    fn thinking_omitted_when_effort_unset() {
1155        let (thinking, output_config) = thinking_request_fields(ThinkingMode::Adaptive, None);
1156        assert!(thinking.is_none());
1157        assert!(output_config.is_none());
1158    }
1159
1160    #[test]
1161    fn thinking_mode_none_sends_no_thinking_fields() {
1162        let (thinking, output_config) =
1163            thinking_request_fields(ThinkingMode::None, Some(Effort::High));
1164        assert!(thinking.is_none());
1165        assert!(output_config.is_none());
1166    }
1167
1168    #[test]
1169    fn test_convert_messages_merges_consecutive_user() {
1170        let input = [
1171            assistant_tool(None, "bash", serde_json::json!({})),
1172            tool_results(&["ok"]),
1173            user("hi"),
1174        ];
1175        let msgs = convert_messages(&input).unwrap();
1176        assert_eq!(msgs.len(), 2);
1177        assert!(matches!(msgs[1].role, AnthropicRole::User));
1178        assert_eq!(msgs[1].content.len(), 2);
1179        // tool_result blocks must come first in a user message.
1180        assert!(matches!(
1181            msgs[1].content[0],
1182            AnthropicContent::ToolResult { .. }
1183        ));
1184        assert!(matches!(msgs[1].content[1], AnthropicContent::Text { .. }));
1185    }
1186
1187    /// History carries no tool ids, but the wire must: minted ids have to be
1188    /// legal under Anthropic's `^[a-zA-Z0-9_-]+$` and keep the `tool_use` and
1189    /// its `tool_result` naming each other.
1190    #[test]
1191    fn wire_carries_legal_minted_ids_that_pair_up() {
1192        let input = [
1193            user("hello"),
1194            assistant_tool(None, "bash", serde_json::json!({})),
1195            tool_results(&["ok"]),
1196        ];
1197        let json = serde_json::to_value(convert_messages(&input).unwrap()).unwrap();
1198
1199        let tool_use_id = json[1]["content"][0]["id"].as_str().unwrap();
1200        assert!(
1201            is_legal_anthropic_id(tool_use_id),
1202            "tool_use id {tool_use_id:?} violates ^[a-zA-Z0-9_-]+$"
1203        );
1204        assert_eq!(tool_use_id, json[2]["content"][0]["tool_use_id"]);
1205    }
1206
1207    #[test]
1208    fn message_start_usage_is_captured() {
1209        let mut acc = StreamAccumulator::default();
1210        let event: AnthropicStreamEvent = serde_json::from_str(
1211            r#"{"type":"message_start","message":{"role":"assistant","usage":{"input_tokens":2095,"cache_read_input_tokens":100,"cache_creation_input_tokens":0,"output_tokens":1}}}"#,
1212        )
1213        .unwrap();
1214        let usage = acc
1215            .handle_event(event)
1216            .unwrap()
1217            .into_iter()
1218            .find_map(|p| match p {
1219                MessagePart::Usage(u) => Some(u),
1220                _ => None,
1221            })
1222            .expect("message_start should emit usage");
1223        assert_eq!(usage.input_tokens, 2195);
1224        assert_eq!(usage.cached_input_tokens, 100);
1225        assert_eq!(usage.context_tokens(), 2195);
1226    }
1227
1228    #[test]
1229    fn thinking_delta_maps_to_content_delta() {
1230        let mut acc = StreamAccumulator::default();
1231        let parts = acc
1232            .handle_event(AnthropicStreamEvent::ContentBlockStart {
1233                index: 0,
1234                content_block: AnthropicStreamContentBlock::Thinking {
1235                    thinking: String::new(),
1236                    signature: Some("sig123".into()),
1237                },
1238            })
1239            .unwrap();
1240        assert!(matches!(
1241            &parts[0],
1242            MessagePart::ContentStart(ContentStart::Thinking {
1243                index: 0,
1244                signature: Some(s),
1245                redacted: false,
1246            }) if s == "sig123"
1247        ));
1248
1249        let parts = acc
1250            .handle_event(AnthropicStreamEvent::ContentBlockDelta {
1251                index: 0,
1252                delta: AnthropicDelta::ThinkingDelta {
1253                    thinking: "step 1".into(),
1254                },
1255            })
1256            .unwrap();
1257        expect_thinking_delta(&parts[0], 0, "step 1");
1258
1259        let parts = acc
1260            .handle_event(AnthropicStreamEvent::ContentBlockStart {
1261                index: 1,
1262                content_block: AnthropicStreamContentBlock::Text { text: "hi".into() },
1263            })
1264            .unwrap();
1265        // content_index is remapped: thinking occupied content slot 0, text is 1.
1266        assert!(matches!(
1267            &parts[0],
1268            MessagePart::ContentStart(ContentStart::Text { index: 1 })
1269        ));
1270        expect_text_delta(&parts[1], 1, "hi");
1271    }
1272
1273    #[test]
1274    fn test_stream_accumulator_text_and_tool_index_remap() {
1275        let mut acc = StreamAccumulator::default();
1276
1277        let items = acc
1278            .handle_event(AnthropicStreamEvent::ContentBlockStart {
1279                index: 0,
1280                content_block: AnthropicStreamContentBlock::Text {
1281                    text: String::new(),
1282                },
1283            })
1284            .unwrap();
1285        assert!(matches!(
1286            items[0],
1287            MessagePart::ContentStart(ContentStart::Text { index: 0 })
1288        ));
1289
1290        let items = acc
1291            .handle_event(AnthropicStreamEvent::ContentBlockDelta {
1292                index: 0,
1293                delta: AnthropicDelta::TextDelta { text: "Hi".into() },
1294            })
1295            .unwrap();
1296        expect_text_delta(&items[0], 0, "Hi");
1297
1298        let items = acc
1299            .handle_event(AnthropicStreamEvent::ContentBlockStart {
1300                index: 1,
1301                content_block: AnthropicStreamContentBlock::ToolUse {
1302                    name: "get_weather".into(),
1303                    input: serde_json::json!({}),
1304                },
1305            })
1306            .unwrap();
1307        // Remapped tool index: tool uses get their own index space.
1308        expect_tool_start(&items[0], 0, "get_weather");
1309
1310        let items = acc
1311            .handle_event(AnthropicStreamEvent::ContentBlockDelta {
1312                index: 1,
1313                delta: AnthropicDelta::InputJsonDelta {
1314                    partial_json: r#"{"city":"SF"}"#.into(),
1315                },
1316            })
1317            .unwrap();
1318        expect_tool_args_delta(&items[0], 0, r#"{"city":"SF"}"#);
1319
1320        acc.handle_event(AnthropicStreamEvent::MessageDelta {
1321            delta: AnthropicMessageDelta {
1322                stop_reason: Some(AnthropicStopReason::ToolUse),
1323            },
1324            usage: None,
1325        })
1326        .unwrap();
1327        acc.handle_event(AnthropicStreamEvent::MessageStop).unwrap();
1328        acc.finish().unwrap();
1329    }
1330}