1use std::sync::Arc;
10
11use super::driver_core::{Slot, SlotMap, SseAccumulator};
12use super::*;
13
14#[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 #[serde(default = "default_max_request_bytes")]
23 pub max_request_bytes: usize,
24 #[serde(default)]
26 pub retry: RetryPolicy,
27 pub effort: Option<Effort>,
36}
37
38impl Default for AnthropicBackendConfig {
39 fn default() -> Self {
40 Self {
41 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
53pub 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 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 fn message_request(&self, messages: &[AnthropicMessage]) -> reqwest::RequestBuilder {
102 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 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
159struct MessageRun {
168 role: AnthropicRole,
169 content: Vec<AnthropicContent>,
170}
171
172fn 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 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 if blocks.is_empty() {
225 continue;
226 }
227 (AnthropicRole::Assistant, blocks)
228 }
229 };
230
231 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 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#[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 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 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 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#[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#[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 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#[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#[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 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#[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 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
711fn 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#[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 #[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
766fn 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 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 ThinkingMode::Effort | ThinkingMode::None => (None, None),
796 }
797}
798
799#[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 #[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#[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 assert_eq!(json[2]["content"][0]["cache_control"]["type"], "ephemeral");
927 assert_eq!(json[1]["content"][0]["cache_control"]["type"], "ephemeral");
928 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 assert_eq!(json[1]["content"][1]["cache_control"]["type"], "ephemeral");
967 assert!(json[1]["content"][0].get("cache_control").is_none());
968 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 #[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 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 assert!(matches!(
1181 msgs[1].content[0],
1182 AnthropicContent::ToolResult { .. }
1183 ));
1184 assert!(matches!(msgs[1].content[1], AnthropicContent::Text { .. }));
1185 }
1186
1187 #[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 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 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}