1use crate::provider::{LLMProvider, LLMStreamEvent};
7use crate::providers::anthropic::compat::{
8 AnthropicContentBlock, AnthropicContentDelta, AnthropicDelta, AnthropicError, AnthropicMessagesRequest,
9 AnthropicMessagesResponse, AnthropicStreamEvent, AnthropicUsage, anthropic_stop_reason,
10 convert_anthropic_to_llm_request, convert_llm_to_anthropic_response,
11};
12use axum::{
13 Json, Router,
14 extract::State,
15 http::{HeaderMap, StatusCode},
16 response::{IntoResponse, sse::Event},
17};
18use futures::StreamExt;
19use std::sync::Arc;
20use tokio_stream::wrappers::ReceiverStream;
21use tower_http::cors::CorsLayer;
22
23type AnthropicSseEvent = Result<Event, axum::Error>;
24type AnthropicSseSender = tokio::sync::mpsc::Sender<AnthropicSseEvent>;
25
26#[derive(Clone)]
28pub struct AnthropicApiServerState {
29 provider: Arc<dyn LLMProvider>,
31 model: String,
33}
34
35impl AnthropicApiServerState {
36 pub fn new(provider: Arc<dyn LLMProvider>, model: String) -> Self {
37 Self { provider, model }
38 }
39}
40
41pub fn create_router(state: AnthropicApiServerState) -> Router {
43 Router::new()
44 .route("/v1/messages", axum::routing::post(messages_handler))
45 .with_state(state)
46 .layer(CorsLayer::permissive())
47}
48
49fn merge_header_betas(request: &mut AnthropicMessagesRequest, headers: &HeaderMap) {
50 let Some(header_betas) = headers
51 .get("anthropic-beta")
52 .and_then(|value| value.to_str().ok())
53 .map(|value| {
54 value
55 .split(',')
56 .map(str::trim)
57 .filter(|beta| !beta.is_empty())
58 .map(str::to_string)
59 .collect::<Vec<_>>()
60 })
61 .filter(|betas| !betas.is_empty())
62 else {
63 return;
64 };
65
66 let request_betas = request.betas.get_or_insert_with(Vec::new);
67 for beta in header_betas {
68 if !request_betas.contains(&beta) {
69 request_betas.push(beta);
70 }
71 }
72}
73
74async fn send_stream_event(tx: &AnthropicSseSender, event: AnthropicStreamEvent) -> bool {
75 tx.send(Event::default().json_data(event)).await.is_ok()
76}
77
78async fn send_content_block_start(tx: &AnthropicSseSender, index: u32, content_block: AnthropicContentBlock) -> bool {
79 send_stream_event(tx, AnthropicStreamEvent::ContentBlockStart { index, content_block }).await
80}
81
82async fn send_content_block_delta(tx: &AnthropicSseSender, index: u32, delta: AnthropicContentDelta) -> bool {
83 send_stream_event(tx, AnthropicStreamEvent::ContentBlockDelta { index, delta }).await
84}
85
86async fn send_content_block_stop(tx: &AnthropicSseSender, index: u32) -> bool {
87 send_stream_event(tx, AnthropicStreamEvent::ContentBlockStop { index }).await
88}
89
90async fn messages_handler(
92 State(state): State<AnthropicApiServerState>,
93 headers: HeaderMap,
94 Json(request): Json<AnthropicMessagesRequest>,
95) -> Result<impl IntoResponse, StatusCode> {
96 let mut request = request;
97 merge_header_betas(&mut request, &headers);
98
99 let is_stream = request.stream;
100 let llm_request = convert_anthropic_to_llm_request(request);
101
102 if is_stream {
103 let stream = match state.provider.stream(llm_request).await {
105 Ok(s) => s,
106 Err(_) => {
107 return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Stream error").into_response());
108 }
109 };
110
111 let (tx, rx) = tokio::sync::mpsc::channel(100);
113
114 tokio::spawn(async move {
116 let mut stream = stream;
119 let mut next_content_block_idx = 0u32;
120 let mut open_text_block = None;
121 let mut open_reasoning_block = None;
122
123 let initial_response = AnthropicMessagesResponse {
125 id: uuid::Uuid::new_v4().to_string(),
126 r#type: "message".to_string(),
127 role: "assistant".to_string(),
128 model: state.model.clone(),
129 content: vec![],
130 stop_reason: None,
131 stop_sequence: None,
132 usage: AnthropicUsage { input_tokens: 0, output_tokens: 0 },
133 };
134
135 if !send_stream_event(&tx, AnthropicStreamEvent::MessageStart { message: initial_response }).await {
136 return;
137 }
138
139 while let Some(event_result) = stream.next().await {
140 match event_result {
141 Ok(provider_event) => {
142 match provider_event {
143 LLMStreamEvent::Token { delta } => {
144 if let Some(index) = open_reasoning_block.take()
145 && !send_content_block_stop(&tx, index).await
146 {
147 break;
148 }
149
150 let index = if let Some(index) = open_text_block {
151 index
152 } else {
153 let index = next_content_block_idx;
154 next_content_block_idx += 1;
155 if !send_content_block_start(
156 &tx,
157 index,
158 AnthropicContentBlock::Text {
159 text: String::new(),
160 citations: None,
161 cache_control: None,
162 },
163 )
164 .await
165 {
166 break;
167 }
168 open_text_block = Some(index);
169 index
170 };
171
172 if !send_content_block_delta(
173 &tx,
174 index,
175 AnthropicContentDelta::TextDelta { text: delta },
176 )
177 .await
178 {
179 break;
180 }
181 }
182 LLMStreamEvent::Reasoning { delta } => {
183 if let Some(index) = open_text_block.take()
184 && !send_content_block_stop(&tx, index).await
185 {
186 break;
187 }
188
189 let index = if let Some(index) = open_reasoning_block {
190 index
191 } else {
192 let index = next_content_block_idx;
193 next_content_block_idx += 1;
194 if !send_content_block_start(
195 &tx,
196 index,
197 AnthropicContentBlock::Thinking { thinking: String::new(), signature: None },
198 )
199 .await
200 {
201 break;
202 }
203 open_reasoning_block = Some(index);
204 index
205 };
206
207 if !send_content_block_delta(
208 &tx,
209 index,
210 AnthropicContentDelta::ThinkingDelta { thinking: delta },
211 )
212 .await
213 {
214 break;
215 }
216 }
217 LLMStreamEvent::ReasoningSignature { signature } => {
218 if let Some(index) = open_reasoning_block
219 && !send_content_block_delta(
220 &tx,
221 index,
222 AnthropicContentDelta::SignatureDelta { signature },
223 )
224 .await
225 {
226 break;
227 }
228 }
229 LLMStreamEvent::ReasoningStage { .. } => {}
230 LLMStreamEvent::Completed { response } => {
231 if let Some(index) = open_reasoning_block.take()
232 && !send_content_block_stop(&tx, index).await
233 {
234 break;
235 }
236 if let Some(index) = open_text_block.take()
237 && !send_content_block_stop(&tx, index).await
238 {
239 break;
240 }
241
242 let usage = response.usage.unwrap_or_default();
243 let delta = AnthropicDelta {
244 stop_reason: Some(anthropic_stop_reason(response.finish_reason)),
245 stop_sequence: None,
246 };
247
248 if !send_stream_event(
249 &tx,
250 AnthropicStreamEvent::MessageDelta {
251 delta,
252 usage: AnthropicUsage {
253 input_tokens: usage.prompt_tokens,
254 output_tokens: usage.completion_tokens,
255 },
256 },
257 )
258 .await
259 {
260 break;
261 }
262
263 if !send_stream_event(&tx, AnthropicStreamEvent::MessageStop).await {
264 break;
265 }
266
267 break; }
269 }
270 }
271 Err(e) => {
272 let error_event = AnthropicStreamEvent::Error {
273 error: AnthropicError {
274 r#type: "error".to_string(),
275 message: e.to_string(),
276 },
277 };
278
279 if !send_stream_event(&tx, error_event).await {
280 break;
281 }
282 break;
283 }
284 }
285 }
286 });
287
288 Ok(axum::response::Sse::new(ReceiverStream::new(rx)).into_response())
289 } else {
290 let response = match state.provider.generate(llm_request).await {
292 Ok(r) => r,
293 Err(_) => {
294 return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Generation error").into_response());
295 }
296 };
297
298 let anthropic_response = convert_llm_to_anthropic_response(response);
299 Ok(Json(anthropic_response).into_response())
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use crate::provider::{
307 AnthropicOptionalStringOverride, AnthropicOptionalU32Override, AnthropicThinkingDisplayOverride,
308 AnthropicThinkingModeOverride, ContentPart, MessageContent, ToolChoice,
309 };
310 use crate::providers::anthropic::compat::{AnthropicContent, AnthropicMessage, AnthropicTool};
311 use crate::providers::anthropic_types::{
312 AnthropicOutputConfig, AnthropicOutputFormat, AnthropicTaskBudget, ThinkingConfig, ThinkingDisplay,
313 };
314 use serde_json::json;
315
316 #[test]
317 fn convert_anthropic_to_llm_request_preserves_web_search_options() {
318 let request = AnthropicMessagesRequest {
319 model: "claude-sonnet-5".to_string(),
320 max_tokens: 1024,
321 messages: vec![AnthropicMessage {
322 role: "user".to_string(),
323 content: AnthropicContent::Text("search docs".to_string()),
324 }],
325 system: None,
326 stream: false,
327 temperature: None,
328 top_p: None,
329 top_k: None,
330 stop_sequences: None,
331 tools: Some(vec![AnthropicTool::Native {
332 tool_type: "web_search_20260209".to_string(),
333 name: "web_search".to_string(),
334 options: json!({
335 "allowed_callers": ["direct"]
336 })
337 .as_object()
338 .cloned()
339 .expect("object config"),
340 }]),
341 tool_choice: None,
342 thinking: None,
343 betas: None,
344 context_management: None,
345 output_config: None,
346 };
347
348 let llm_request = convert_anthropic_to_llm_request(request);
349 let tools = llm_request.tools.expect("tools");
350 assert_eq!(tools.len(), 1);
351 assert_eq!(tools[0].tool_type, "web_search_20260209");
352 assert_eq!(
353 tools[0].web_search.as_ref(),
354 Some(&json!({
355 "allowed_callers": ["direct"]
356 }))
357 );
358 }
359
360 #[test]
361 fn convert_anthropic_to_llm_request_preserves_function_allowed_callers() {
362 let request = AnthropicMessagesRequest {
363 model: "claude-sonnet-5".to_string(),
364 max_tokens: 1024,
365 messages: vec![AnthropicMessage {
366 role: "user".to_string(),
367 content: AnthropicContent::Text("find warmest city".to_string()),
368 }],
369 system: None,
370 stream: false,
371 temperature: None,
372 top_p: None,
373 top_k: None,
374 stop_sequences: None,
375 tools: Some(vec![AnthropicTool::Function {
376 name: "get_weather".to_string(),
377 description: Some("Get weather for a city".to_string()),
378 input_schema: json!({
379 "type": "object",
380 "properties": {
381 "city": {"type": "string"}
382 },
383 "required": ["city"]
384 }),
385 input_examples: None,
386 strict: None,
387 allowed_callers: Some(vec!["code_execution_20250825".to_string()]),
388 }]),
389 tool_choice: None,
390 thinking: None,
391 betas: None,
392 context_management: None,
393 output_config: None,
394 };
395
396 let llm_request = convert_anthropic_to_llm_request(request);
397 let tools = llm_request.tools.expect("tools");
398 assert_eq!(tools[0].allowed_callers.as_ref(), Some(&vec!["code_execution_20250825".to_string()]));
399 }
400
401 #[test]
402 fn convert_anthropic_to_llm_request_preserves_strict_and_input_examples() {
403 let request = AnthropicMessagesRequest {
404 model: "claude-sonnet-5".to_string(),
405 max_tokens: 1024,
406 messages: vec![AnthropicMessage {
407 role: "user".to_string(),
408 content: AnthropicContent::Text("find warmest city".to_string()),
409 }],
410 system: None,
411 stream: false,
412 temperature: None,
413 top_p: None,
414 top_k: None,
415 stop_sequences: None,
416 tools: Some(vec![AnthropicTool::Function {
417 name: "get_weather".to_string(),
418 description: Some("Get weather for a city".to_string()),
419 input_schema: json!({
420 "type": "object",
421 "properties": {
422 "city": {"type": "string"}
423 },
424 "required": ["city"]
425 }),
426 input_examples: Some(vec![json!({
427 "input": "Weather in Paris",
428 "tool_use": {
429 "city": "Paris"
430 }
431 })]),
432 strict: Some(true),
433 allowed_callers: None,
434 }]),
435 tool_choice: None,
436 thinking: None,
437 betas: None,
438 context_management: None,
439 output_config: None,
440 };
441
442 let llm_request = convert_anthropic_to_llm_request(request);
443 let tools = llm_request.tools.expect("tools");
444 assert_eq!(tools[0].strict, Some(true));
445 assert_eq!(
446 tools[0].input_examples.as_ref(),
447 Some(&vec![json!({
448 "input": "Weather in Paris",
449 "tool_use": {
450 "city": "Paris"
451 }
452 })])
453 );
454 }
455
456 #[test]
457 fn convert_anthropic_to_llm_request_accepts_native_code_execution_tool() {
458 let request = AnthropicMessagesRequest {
459 model: "claude-sonnet-5".to_string(),
460 max_tokens: 1024,
461 messages: vec![AnthropicMessage {
462 role: "user".to_string(),
463 content: AnthropicContent::Text("run python".to_string()),
464 }],
465 system: None,
466 stream: false,
467 temperature: None,
468 top_p: None,
469 top_k: None,
470 stop_sequences: None,
471 tools: Some(vec![AnthropicTool::Native {
472 tool_type: "code_execution_20250825".to_string(),
473 name: "code_execution".to_string(),
474 options: serde_json::Map::new(),
475 }]),
476 tool_choice: None,
477 thinking: None,
478 betas: None,
479 context_management: None,
480 output_config: None,
481 };
482
483 let llm_request = convert_anthropic_to_llm_request(request);
484 let tools = llm_request.tools.expect("tools");
485 assert_eq!(tools[0].tool_type, "code_execution_20250825");
486 }
487
488 #[test]
489 fn convert_anthropic_to_llm_request_accepts_native_memory_tool() {
490 let request = AnthropicMessagesRequest {
491 model: "claude-sonnet-5".to_string(),
492 max_tokens: 1024,
493 messages: vec![AnthropicMessage {
494 role: "user".to_string(),
495 content: AnthropicContent::Text("remember this preference".to_string()),
496 }],
497 system: None,
498 stream: false,
499 temperature: None,
500 top_p: None,
501 top_k: None,
502 stop_sequences: None,
503 tools: Some(vec![AnthropicTool::Native {
504 tool_type: "memory_20250818".to_string(),
505 name: "memory".to_string(),
506 options: serde_json::Map::new(),
507 }]),
508 tool_choice: None,
509 thinking: None,
510 betas: None,
511 context_management: None,
512 output_config: None,
513 };
514
515 let llm_request = convert_anthropic_to_llm_request(request);
516 let tools = llm_request.tools.expect("tools");
517 assert_eq!(tools[0].tool_type, "memory_20250818");
518 }
519
520 #[test]
521 fn convert_anthropic_to_llm_request_maps_container_upload_to_file_part() {
522 let request = AnthropicMessagesRequest {
523 model: "claude-sonnet-5".to_string(),
524 max_tokens: 1024,
525 messages: vec![AnthropicMessage {
526 role: "user".to_string(),
527 content: AnthropicContent::Blocks(vec![
528 AnthropicContentBlock::Text {
529 text: "Analyze this CSV".to_string(),
530 citations: None,
531 cache_control: None,
532 },
533 AnthropicContentBlock::ContainerUpload { file_id: "file_abc123".to_string() },
534 ]),
535 }],
536 system: None,
537 stream: false,
538 temperature: None,
539 top_p: None,
540 top_k: None,
541 stop_sequences: None,
542 tools: None,
543 tool_choice: None,
544 thinking: None,
545 betas: None,
546 context_management: None,
547 output_config: None,
548 };
549
550 let llm_request = convert_anthropic_to_llm_request(request);
551 match &llm_request.messages[0].content {
552 MessageContent::Parts(parts) => {
553 assert!(matches!(
554 &parts[0],
555 ContentPart::Text { text } if text == "Analyze this CSV"
556 ));
557 assert!(matches!(
558 &parts[1],
559 ContentPart::File {
560 file_id: Some(file_id),
561 ..
562 } if file_id == "file_abc123"
563 ));
564 }
565 other => panic!("expected multipart content, got {other:?}"),
566 }
567 }
568
569 #[test]
570 fn convert_anthropic_to_llm_request_maps_native_structured_output_config() {
571 let request = AnthropicMessagesRequest {
572 model: "claude-sonnet-5".to_string(),
573 max_tokens: 1024,
574 messages: vec![AnthropicMessage {
575 role: "user".to_string(),
576 content: AnthropicContent::Text("answer in json".to_string()),
577 }],
578 system: None,
579 stream: false,
580 temperature: None,
581 top_p: None,
582 top_k: None,
583 stop_sequences: None,
584 tools: None,
585 tool_choice: None,
586 thinking: None,
587 betas: None,
588 context_management: None,
589 output_config: Some(AnthropicOutputConfig {
590 effort: Some("medium".to_string()),
591 task_budget: None,
592 format: Some(AnthropicOutputFormat::JsonSchema {
593 schema: json!({
594 "type": "object",
595 "properties": {
596 "answer": {"type": "string"}
597 },
598 "required": ["answer"],
599 "additionalProperties": false
600 }),
601 }),
602 }),
603 };
604
605 let llm_request = convert_anthropic_to_llm_request(request);
606 assert_eq!(llm_request.effort.as_deref(), Some("medium"));
607 assert_eq!(
608 llm_request.output_format,
609 Some(json!({
610 "type": "object",
611 "properties": {
612 "answer": {"type": "string"}
613 },
614 "required": ["answer"],
615 "additionalProperties": false
616 }))
617 );
618 }
619
620 #[test]
621 fn convert_anthropic_to_llm_request_maps_thinking_display_effort_and_task_budget() {
622 let request = AnthropicMessagesRequest {
623 model: "claude-sonnet-5".to_string(),
624 max_tokens: 1024,
625 messages: vec![AnthropicMessage {
626 role: "user".to_string(),
627 content: AnthropicContent::Text("hello".to_string()),
628 }],
629 system: None,
630 stream: false,
631 temperature: None,
632 top_p: None,
633 top_k: None,
634 stop_sequences: None,
635 tools: None,
636 tool_choice: None,
637 thinking: Some(ThinkingConfig::Adaptive { display: Some(ThinkingDisplay::Summarized) }),
638 betas: None,
639 context_management: None,
640 output_config: Some(AnthropicOutputConfig {
641 effort: Some("medium".to_string()),
642 task_budget: Some(AnthropicTaskBudget { budget_type: "tokens".to_string(), total: 64_000 }),
643 format: None,
644 }),
645 };
646
647 let llm_request = convert_anthropic_to_llm_request(request);
648 let overrides = llm_request.anthropic_request_overrides.expect("anthropic overrides");
649 assert_eq!(overrides.thinking_mode, AnthropicThinkingModeOverride::Adaptive);
650 assert_eq!(overrides.thinking_display, AnthropicThinkingDisplayOverride::Summarized);
651 assert_eq!(overrides.effort, AnthropicOptionalStringOverride::Explicit("medium".to_string()));
652 assert_eq!(overrides.task_budget_tokens, AnthropicOptionalU32Override::Explicit(64_000));
653 }
654
655 #[test]
656 fn convert_anthropic_to_llm_request_maps_manual_budget_thinking_mode() {
657 let request = AnthropicMessagesRequest {
658 model: "claude-sonnet-5".to_string(),
659 max_tokens: 1024,
660 messages: vec![AnthropicMessage {
661 role: "user".to_string(),
662 content: AnthropicContent::Text("hello".to_string()),
663 }],
664 system: None,
665 stream: false,
666 temperature: None,
667 top_p: None,
668 top_k: None,
669 stop_sequences: None,
670 tools: None,
671 tool_choice: None,
672 thinking: Some(ThinkingConfig::Enabled {
673 budget_tokens: 4096,
674 display: Some(ThinkingDisplay::Omitted),
675 }),
676 betas: None,
677 context_management: None,
678 output_config: None,
679 };
680
681 let llm_request = convert_anthropic_to_llm_request(request);
682 let overrides = llm_request.anthropic_request_overrides.expect("anthropic overrides");
683 assert_eq!(overrides.thinking_mode, AnthropicThinkingModeOverride::ManualBudget(4096));
684 assert_eq!(overrides.thinking_display, AnthropicThinkingDisplayOverride::Omitted);
685 }
686
687 #[test]
688 fn convert_anthropic_to_llm_request_preserves_assistant_tool_calls_and_reasoning() {
689 let request = AnthropicMessagesRequest {
690 model: "claude-sonnet-5".to_string(),
691 max_tokens: 1024,
692 messages: vec![AnthropicMessage {
693 role: "assistant".to_string(),
694 content: AnthropicContent::Blocks(vec![
695 AnthropicContentBlock::Thinking {
696 thinking: "inspect files".to_string(),
697 signature: None,
698 },
699 AnthropicContentBlock::Text {
700 text: "Calling exec_command".to_string(),
701 citations: None,
702 cache_control: None,
703 },
704 AnthropicContentBlock::ToolUse {
705 id: "call_123".to_string(),
706 name: "exec_command".to_string(),
707 input: json!({"path": "src/main.rs"}),
708 },
709 ]),
710 }],
711 system: None,
712 stream: false,
713 temperature: None,
714 top_p: None,
715 top_k: None,
716 stop_sequences: None,
717 tools: None,
718 tool_choice: None,
719 thinking: None,
720 betas: None,
721 context_management: None,
722 output_config: None,
723 };
724
725 let llm_request = convert_anthropic_to_llm_request(request);
726 assert_eq!(llm_request.messages.len(), 1);
727 let message = &llm_request.messages[0];
728 assert_eq!(message.reasoning.as_deref(), Some("inspect files"));
729 assert_eq!(message.content.as_text().as_ref(), "Calling exec_command");
730 assert_eq!(
731 message
732 .tool_calls
733 .as_ref()
734 .and_then(|calls| calls.first())
735 .and_then(|call| call.function.as_ref())
736 .map(|function| function.name.as_str()),
737 Some("exec_command")
738 );
739 }
740
741 #[test]
742 fn convert_anthropic_to_llm_request_maps_disable_parallel_tool_use() {
743 let request = AnthropicMessagesRequest {
744 model: "claude-sonnet-5".to_string(),
745 max_tokens: 1024,
746 messages: vec![AnthropicMessage {
747 role: "user".to_string(),
748 content: AnthropicContent::Text("use one tool at a time".to_string()),
749 }],
750 system: None,
751 stream: false,
752 temperature: None,
753 top_p: None,
754 top_k: None,
755 stop_sequences: None,
756 tools: None,
757 tool_choice: Some(json!({
758 "type": "auto",
759 "disable_parallel_tool_use": true
760 })),
761 thinking: None,
762 betas: None,
763 context_management: None,
764 output_config: None,
765 };
766
767 let llm_request = convert_anthropic_to_llm_request(request);
768 assert!(matches!(llm_request.tool_choice, Some(ToolChoice::Auto)));
769 assert!(
770 llm_request
771 .parallel_tool_config
772 .as_ref()
773 .is_some_and(|config| config.disable_parallel_tool_use)
774 );
775 }
776
777 #[test]
778 fn anthropic_content_block_thinking_uses_anthropic_wire_field() {
779 let block = AnthropicContentBlock::Thinking { thinking: "plan".to_string(), signature: None };
780
781 let serialized = serde_json::to_value(block).expect("serialize thinking block");
782 assert_eq!(serialized["type"], "thinking");
783 assert_eq!(serialized["thinking"], "plan");
784 assert!(serialized.get("text").is_none());
785 }
786
787 #[test]
788 fn anthropic_content_delta_thinking_uses_anthropic_wire_field() {
789 let delta = AnthropicContentDelta::ThinkingDelta { thinking: "draft".to_string() };
790
791 let serialized = serde_json::to_value(delta).expect("serialize thinking delta");
792 assert_eq!(serialized["type"], "thinking_delta");
793 assert_eq!(serialized["thinking"], "draft");
794 assert!(serialized.get("text").is_none());
795 }
796
797 #[test]
798 fn convert_llm_to_anthropic_response_preserves_reasoning_and_model() {
799 let response = crate::provider::LLMResponse {
800 content: Some("Done".to_string()),
801 model: "claude-sonnet-5".to_string(),
802 reasoning: Some("inspect files".to_string()),
803 ..Default::default()
804 };
805
806 let anthropic = convert_llm_to_anthropic_response(response);
807 assert_eq!(anthropic.model, "claude-sonnet-5");
808 assert!(matches!(
809 anthropic.content.first(),
810 Some(AnthropicContentBlock::Thinking { thinking, .. }) if thinking == "inspect files"
811 ));
812 assert!(matches!(
813 anthropic.content.get(1),
814 Some(AnthropicContentBlock::Text { text, .. }) if text == "Done"
815 ));
816 }
817
818 #[test]
819 fn convert_llm_to_anthropic_response_preserves_reasoning_signature_details() {
820 let response = crate::provider::LLMResponse {
821 model: "claude-sonnet-5".to_string(),
822 reasoning_details: Some(vec![
823 json!({
824 "type": "thinking",
825 "thinking": "",
826 "signature": "sig_123",
827 })
828 .to_string(),
829 json!({
830 "type": "redacted_thinking",
831 "data": "encrypted",
832 })
833 .to_string(),
834 ]),
835 ..Default::default()
836 };
837
838 let anthropic = convert_llm_to_anthropic_response(response);
839 assert!(matches!(
840 anthropic.content.first(),
841 Some(AnthropicContentBlock::Thinking { thinking, signature })
842 if thinking.is_empty() && signature.as_deref() == Some("sig_123")
843 ));
844 assert!(matches!(
845 anthropic.content.get(1),
846 Some(AnthropicContentBlock::RedactedThinking { data }) if data == "encrypted"
847 ));
848 }
849}