1use crate::llm::provider::{LLMProvider, LLMStreamEvent};
7use crate::llm::providers::anthropic::compat::{
8 AnthropicContentBlock, AnthropicContentDelta, AnthropicDelta, AnthropicError,
9 AnthropicMessagesRequest, AnthropicMessagesResponse, AnthropicStreamEvent, AnthropicUsage,
10 anthropic_stop_reason, 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 pub provider: Arc<dyn LLMProvider>,
31 pub 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(
79 tx: &AnthropicSseSender,
80 index: u32,
81 content_block: AnthropicContentBlock,
82) -> bool {
83 send_stream_event(
84 tx,
85 AnthropicStreamEvent::ContentBlockStart {
86 index,
87 content_block,
88 },
89 )
90 .await
91}
92
93async fn send_content_block_delta(
94 tx: &AnthropicSseSender,
95 index: u32,
96 delta: AnthropicContentDelta,
97) -> bool {
98 send_stream_event(tx, AnthropicStreamEvent::ContentBlockDelta { index, delta }).await
99}
100
101async fn send_content_block_stop(tx: &AnthropicSseSender, index: u32) -> bool {
102 send_stream_event(tx, AnthropicStreamEvent::ContentBlockStop { index }).await
103}
104
105pub async fn messages_handler(
107 State(state): State<AnthropicApiServerState>,
108 headers: HeaderMap,
109 Json(request): Json<AnthropicMessagesRequest>,
110) -> Result<impl IntoResponse, StatusCode> {
111 let mut request = request;
112 merge_header_betas(&mut request, &headers);
113
114 let is_stream = request.stream;
115 let llm_request = convert_anthropic_to_llm_request(request);
116
117 if is_stream {
118 let stream = match state.provider.stream(llm_request).await {
120 Ok(s) => s,
121 Err(_) => {
122 return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Stream error").into_response());
123 }
124 };
125
126 let (tx, rx) = tokio::sync::mpsc::channel(100);
128
129 tokio::spawn(async move {
131 let mut stream = Box::pin(stream);
132 let mut next_content_block_idx = 0u32;
133 let mut open_text_block = None;
134 let mut open_reasoning_block = None;
135
136 let initial_response = AnthropicMessagesResponse {
138 id: uuid::Uuid::new_v4().to_string(),
139 r#type: "message".to_string(),
140 role: "assistant".to_string(),
141 model: state.model.clone(),
142 content: vec![],
143 stop_reason: None,
144 stop_sequence: None,
145 usage: AnthropicUsage {
146 input_tokens: 0,
147 output_tokens: 0,
148 },
149 };
150
151 if !send_stream_event(
152 &tx,
153 AnthropicStreamEvent::MessageStart {
154 message: initial_response,
155 },
156 )
157 .await
158 {
159 return;
160 }
161
162 while let Some(event_result) = stream.next().await {
163 match event_result {
164 Ok(provider_event) => {
165 match provider_event {
166 LLMStreamEvent::Token { delta } => {
167 if let Some(index) = open_reasoning_block.take()
168 && !send_content_block_stop(&tx, index).await
169 {
170 break;
171 }
172
173 let index = if let Some(index) = open_text_block {
174 index
175 } else {
176 let index = next_content_block_idx;
177 next_content_block_idx += 1;
178 if !send_content_block_start(
179 &tx,
180 index,
181 AnthropicContentBlock::Text {
182 text: String::new(),
183 citations: None,
184 cache_control: None,
185 },
186 )
187 .await
188 {
189 break;
190 }
191 open_text_block = Some(index);
192 index
193 };
194
195 if !send_content_block_delta(
196 &tx,
197 index,
198 AnthropicContentDelta::TextDelta { text: delta },
199 )
200 .await
201 {
202 break;
203 }
204 }
205 LLMStreamEvent::Reasoning { delta } => {
206 if let Some(index) = open_text_block.take()
207 && !send_content_block_stop(&tx, index).await
208 {
209 break;
210 }
211
212 let index = if let Some(index) = open_reasoning_block {
213 index
214 } else {
215 let index = next_content_block_idx;
216 next_content_block_idx += 1;
217 if !send_content_block_start(
218 &tx,
219 index,
220 AnthropicContentBlock::Thinking {
221 thinking: String::new(),
222 signature: None,
223 },
224 )
225 .await
226 {
227 break;
228 }
229 open_reasoning_block = Some(index);
230 index
231 };
232
233 if !send_content_block_delta(
234 &tx,
235 index,
236 AnthropicContentDelta::ThinkingDelta { thinking: delta },
237 )
238 .await
239 {
240 break;
241 }
242 }
243 LLMStreamEvent::ReasoningSignature { signature } => {
244 if let Some(index) = open_reasoning_block
245 && !send_content_block_delta(
246 &tx,
247 index,
248 AnthropicContentDelta::SignatureDelta { signature },
249 )
250 .await
251 {
252 break;
253 }
254 }
255 LLMStreamEvent::ReasoningStage { .. } => {}
256 LLMStreamEvent::Completed { response } => {
257 if let Some(index) = open_reasoning_block.take()
258 && !send_content_block_stop(&tx, index).await
259 {
260 break;
261 }
262 if let Some(index) = open_text_block.take()
263 && !send_content_block_stop(&tx, index).await
264 {
265 break;
266 }
267
268 let usage = response.usage.unwrap_or_default();
269 let delta = AnthropicDelta {
270 stop_reason: Some(anthropic_stop_reason(
271 response.finish_reason,
272 )),
273 stop_sequence: None,
274 };
275
276 if !send_stream_event(
277 &tx,
278 AnthropicStreamEvent::MessageDelta {
279 delta,
280 usage: AnthropicUsage {
281 input_tokens: usage.prompt_tokens,
282 output_tokens: usage.completion_tokens,
283 },
284 },
285 )
286 .await
287 {
288 break;
289 }
290
291 if !send_stream_event(&tx, AnthropicStreamEvent::MessageStop).await
292 {
293 break;
294 }
295
296 break; }
298 }
299 }
300 Err(e) => {
301 let error_event = AnthropicStreamEvent::Error {
302 error: AnthropicError {
303 r#type: "error".to_string(),
304 message: e.to_string(),
305 },
306 };
307
308 if !send_stream_event(&tx, error_event).await {
309 break;
310 }
311 break;
312 }
313 }
314 }
315 });
316
317 Ok(axum::response::Sse::new(ReceiverStream::new(rx)).into_response())
318 } else {
319 let response = match state.provider.generate(llm_request).await {
321 Ok(r) => r,
322 Err(_) => {
323 return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Generation error").into_response());
324 }
325 };
326
327 let anthropic_response = convert_llm_to_anthropic_response(response);
328 Ok(Json(anthropic_response).into_response())
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use crate::llm::provider::{
336 AnthropicOptionalStringOverride, AnthropicOptionalU32Override,
337 AnthropicThinkingDisplayOverride, AnthropicThinkingModeOverride, ContentPart,
338 MessageContent, ToolChoice,
339 };
340 use crate::llm::providers::anthropic::compat::{
341 AnthropicContent, AnthropicMessage, AnthropicTool,
342 };
343 use crate::llm::providers::anthropic_types::{
344 AnthropicOutputConfig, AnthropicOutputFormat, AnthropicTaskBudget, ThinkingConfig,
345 ThinkingDisplay,
346 };
347 use serde_json::json;
348
349 #[test]
350 fn convert_anthropic_to_llm_request_preserves_web_search_options() {
351 let request = AnthropicMessagesRequest {
352 model: "claude-opus-4-7".to_string(),
353 max_tokens: 1024,
354 messages: vec![AnthropicMessage {
355 role: "user".to_string(),
356 content: AnthropicContent::Text("search docs".to_string()),
357 }],
358 system: None,
359 stream: false,
360 temperature: None,
361 top_p: None,
362 top_k: None,
363 stop_sequences: None,
364 tools: Some(vec![AnthropicTool::Native {
365 tool_type: "web_search_20260209".to_string(),
366 name: "web_search".to_string(),
367 options: json!({
368 "allowed_callers": ["direct"]
369 })
370 .as_object()
371 .cloned()
372 .expect("object config"),
373 }]),
374 tool_choice: None,
375 thinking: None,
376 betas: None,
377 context_management: None,
378 output_config: None,
379 };
380
381 let llm_request = convert_anthropic_to_llm_request(request);
382 let tools = llm_request.tools.expect("tools");
383 assert_eq!(tools.len(), 1);
384 assert_eq!(tools[0].tool_type, "web_search_20260209");
385 assert_eq!(
386 tools[0].web_search.as_ref(),
387 Some(&json!({
388 "allowed_callers": ["direct"]
389 }))
390 );
391 }
392
393 #[test]
394 fn convert_anthropic_to_llm_request_preserves_function_allowed_callers() {
395 let request = AnthropicMessagesRequest {
396 model: "claude-opus-4-7".to_string(),
397 max_tokens: 1024,
398 messages: vec![AnthropicMessage {
399 role: "user".to_string(),
400 content: AnthropicContent::Text("find warmest city".to_string()),
401 }],
402 system: None,
403 stream: false,
404 temperature: None,
405 top_p: None,
406 top_k: None,
407 stop_sequences: None,
408 tools: Some(vec![AnthropicTool::Function {
409 name: "get_weather".to_string(),
410 description: Some("Get weather for a city".to_string()),
411 input_schema: json!({
412 "type": "object",
413 "properties": {
414 "city": {"type": "string"}
415 },
416 "required": ["city"]
417 }),
418 input_examples: None,
419 strict: None,
420 allowed_callers: Some(vec!["code_execution_20250825".to_string()]),
421 }]),
422 tool_choice: None,
423 thinking: None,
424 betas: None,
425 context_management: None,
426 output_config: None,
427 };
428
429 let llm_request = convert_anthropic_to_llm_request(request);
430 let tools = llm_request.tools.expect("tools");
431 assert_eq!(
432 tools[0].allowed_callers.as_ref(),
433 Some(&vec!["code_execution_20250825".to_string()])
434 );
435 }
436
437 #[test]
438 fn convert_anthropic_to_llm_request_preserves_strict_and_input_examples() {
439 let request = AnthropicMessagesRequest {
440 model: "claude-opus-4-7".to_string(),
441 max_tokens: 1024,
442 messages: vec![AnthropicMessage {
443 role: "user".to_string(),
444 content: AnthropicContent::Text("find warmest city".to_string()),
445 }],
446 system: None,
447 stream: false,
448 temperature: None,
449 top_p: None,
450 top_k: None,
451 stop_sequences: None,
452 tools: Some(vec![AnthropicTool::Function {
453 name: "get_weather".to_string(),
454 description: Some("Get weather for a city".to_string()),
455 input_schema: json!({
456 "type": "object",
457 "properties": {
458 "city": {"type": "string"}
459 },
460 "required": ["city"]
461 }),
462 input_examples: Some(vec![json!({
463 "input": "Weather in Paris",
464 "tool_use": {
465 "city": "Paris"
466 }
467 })]),
468 strict: Some(true),
469 allowed_callers: None,
470 }]),
471 tool_choice: None,
472 thinking: None,
473 betas: None,
474 context_management: None,
475 output_config: None,
476 };
477
478 let llm_request = convert_anthropic_to_llm_request(request);
479 let tools = llm_request.tools.expect("tools");
480 assert_eq!(tools[0].strict, Some(true));
481 assert_eq!(
482 tools[0].input_examples.as_ref(),
483 Some(&vec![json!({
484 "input": "Weather in Paris",
485 "tool_use": {
486 "city": "Paris"
487 }
488 })])
489 );
490 }
491
492 #[test]
493 fn convert_anthropic_to_llm_request_accepts_native_code_execution_tool() {
494 let request = AnthropicMessagesRequest {
495 model: "claude-opus-4-7".to_string(),
496 max_tokens: 1024,
497 messages: vec![AnthropicMessage {
498 role: "user".to_string(),
499 content: AnthropicContent::Text("run python".to_string()),
500 }],
501 system: None,
502 stream: false,
503 temperature: None,
504 top_p: None,
505 top_k: None,
506 stop_sequences: None,
507 tools: Some(vec![AnthropicTool::Native {
508 tool_type: "code_execution_20250825".to_string(),
509 name: "code_execution".to_string(),
510 options: serde_json::Map::new(),
511 }]),
512 tool_choice: None,
513 thinking: None,
514 betas: None,
515 context_management: None,
516 output_config: None,
517 };
518
519 let llm_request = convert_anthropic_to_llm_request(request);
520 let tools = llm_request.tools.expect("tools");
521 assert_eq!(tools[0].tool_type, "code_execution_20250825");
522 }
523
524 #[test]
525 fn convert_anthropic_to_llm_request_accepts_native_memory_tool() {
526 let request = AnthropicMessagesRequest {
527 model: "claude-opus-4-7".to_string(),
528 max_tokens: 1024,
529 messages: vec![AnthropicMessage {
530 role: "user".to_string(),
531 content: AnthropicContent::Text("remember this preference".to_string()),
532 }],
533 system: None,
534 stream: false,
535 temperature: None,
536 top_p: None,
537 top_k: None,
538 stop_sequences: None,
539 tools: Some(vec![AnthropicTool::Native {
540 tool_type: "memory_20250818".to_string(),
541 name: "memory".to_string(),
542 options: serde_json::Map::new(),
543 }]),
544 tool_choice: None,
545 thinking: None,
546 betas: None,
547 context_management: None,
548 output_config: None,
549 };
550
551 let llm_request = convert_anthropic_to_llm_request(request);
552 let tools = llm_request.tools.expect("tools");
553 assert_eq!(tools[0].tool_type, "memory_20250818");
554 }
555
556 #[test]
557 fn convert_anthropic_to_llm_request_maps_container_upload_to_file_part() {
558 let request = AnthropicMessagesRequest {
559 model: "claude-opus-4-7".to_string(),
560 max_tokens: 1024,
561 messages: vec![AnthropicMessage {
562 role: "user".to_string(),
563 content: AnthropicContent::Blocks(vec![
564 AnthropicContentBlock::Text {
565 text: "Analyze this CSV".to_string(),
566 citations: None,
567 cache_control: None,
568 },
569 AnthropicContentBlock::ContainerUpload {
570 file_id: "file_abc123".to_string(),
571 },
572 ]),
573 }],
574 system: None,
575 stream: false,
576 temperature: None,
577 top_p: None,
578 top_k: None,
579 stop_sequences: None,
580 tools: None,
581 tool_choice: None,
582 thinking: None,
583 betas: None,
584 context_management: None,
585 output_config: None,
586 };
587
588 let llm_request = convert_anthropic_to_llm_request(request);
589 match &llm_request.messages[0].content {
590 MessageContent::Parts(parts) => {
591 assert!(matches!(
592 &parts[0],
593 ContentPart::Text { text } if text == "Analyze this CSV"
594 ));
595 assert!(matches!(
596 &parts[1],
597 ContentPart::File {
598 file_id: Some(file_id),
599 ..
600 } if file_id == "file_abc123"
601 ));
602 }
603 other => panic!("expected multipart content, got {other:?}"),
604 }
605 }
606
607 #[test]
608 fn convert_anthropic_to_llm_request_maps_native_structured_output_config() {
609 let request = AnthropicMessagesRequest {
610 model: "claude-opus-4-7".to_string(),
611 max_tokens: 1024,
612 messages: vec![AnthropicMessage {
613 role: "user".to_string(),
614 content: AnthropicContent::Text("answer in json".to_string()),
615 }],
616 system: None,
617 stream: false,
618 temperature: None,
619 top_p: None,
620 top_k: None,
621 stop_sequences: None,
622 tools: None,
623 tool_choice: None,
624 thinking: None,
625 betas: None,
626 context_management: None,
627 output_config: Some(AnthropicOutputConfig {
628 effort: Some("medium".to_string()),
629 task_budget: None,
630 format: Some(AnthropicOutputFormat::JsonSchema {
631 schema: json!({
632 "type": "object",
633 "properties": {
634 "answer": {"type": "string"}
635 },
636 "required": ["answer"],
637 "additionalProperties": false
638 }),
639 }),
640 }),
641 };
642
643 let llm_request = convert_anthropic_to_llm_request(request);
644 assert_eq!(llm_request.effort.as_deref(), Some("medium"));
645 assert_eq!(
646 llm_request.output_format,
647 Some(json!({
648 "type": "object",
649 "properties": {
650 "answer": {"type": "string"}
651 },
652 "required": ["answer"],
653 "additionalProperties": false
654 }))
655 );
656 }
657
658 #[test]
659 fn convert_anthropic_to_llm_request_defaults_to_disabled_thinking_for_opus_4_7() {
660 let request = AnthropicMessagesRequest {
661 model: "claude-opus-4-7".to_string(),
662 max_tokens: 1024,
663 messages: vec![AnthropicMessage {
664 role: "user".to_string(),
665 content: AnthropicContent::Text("hello".to_string()),
666 }],
667 system: None,
668 stream: false,
669 temperature: None,
670 top_p: None,
671 top_k: None,
672 stop_sequences: None,
673 tools: None,
674 tool_choice: None,
675 thinking: None,
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
683 .anthropic_request_overrides
684 .expect("anthropic overrides");
685 assert_eq!(
686 overrides.thinking_mode,
687 AnthropicThinkingModeOverride::Disabled
688 );
689 }
690
691 #[test]
692 fn convert_anthropic_to_llm_request_defaults_to_adaptive_thinking_for_mythos() {
693 let request = AnthropicMessagesRequest {
694 model: "claude-mythos-preview".to_string(),
695 max_tokens: 1024,
696 messages: vec![AnthropicMessage {
697 role: "user".to_string(),
698 content: AnthropicContent::Text("hello".to_string()),
699 }],
700 system: None,
701 stream: false,
702 temperature: None,
703 top_p: None,
704 top_k: None,
705 stop_sequences: None,
706 tools: None,
707 tool_choice: None,
708 thinking: None,
709 betas: None,
710 context_management: None,
711 output_config: None,
712 };
713
714 let llm_request = convert_anthropic_to_llm_request(request);
715 let overrides = llm_request
716 .anthropic_request_overrides
717 .expect("anthropic overrides");
718 assert_eq!(
719 overrides.thinking_mode,
720 AnthropicThinkingModeOverride::Adaptive
721 );
722 }
723
724 #[test]
725 fn convert_anthropic_to_llm_request_maps_thinking_display_effort_and_task_budget() {
726 let request = AnthropicMessagesRequest {
727 model: "claude-opus-4-7".to_string(),
728 max_tokens: 1024,
729 messages: vec![AnthropicMessage {
730 role: "user".to_string(),
731 content: AnthropicContent::Text("hello".to_string()),
732 }],
733 system: None,
734 stream: false,
735 temperature: None,
736 top_p: None,
737 top_k: None,
738 stop_sequences: None,
739 tools: None,
740 tool_choice: None,
741 thinking: Some(ThinkingConfig::Adaptive {
742 display: Some(ThinkingDisplay::Summarized),
743 }),
744 betas: None,
745 context_management: None,
746 output_config: Some(AnthropicOutputConfig {
747 effort: Some("medium".to_string()),
748 task_budget: Some(AnthropicTaskBudget {
749 budget_type: "tokens".to_string(),
750 total: 64_000,
751 }),
752 format: None,
753 }),
754 };
755
756 let llm_request = convert_anthropic_to_llm_request(request);
757 let overrides = llm_request
758 .anthropic_request_overrides
759 .expect("anthropic overrides");
760 assert_eq!(
761 overrides.thinking_mode,
762 AnthropicThinkingModeOverride::Adaptive
763 );
764 assert_eq!(
765 overrides.thinking_display,
766 AnthropicThinkingDisplayOverride::Summarized
767 );
768 assert_eq!(
769 overrides.effort,
770 AnthropicOptionalStringOverride::Explicit("medium".to_string())
771 );
772 assert_eq!(
773 overrides.task_budget_tokens,
774 AnthropicOptionalU32Override::Explicit(64_000)
775 );
776 }
777
778 #[test]
779 fn convert_anthropic_to_llm_request_maps_manual_budget_thinking_mode() {
780 let request = AnthropicMessagesRequest {
781 model: "claude-opus-4-6".to_string(),
782 max_tokens: 1024,
783 messages: vec![AnthropicMessage {
784 role: "user".to_string(),
785 content: AnthropicContent::Text("hello".to_string()),
786 }],
787 system: None,
788 stream: false,
789 temperature: None,
790 top_p: None,
791 top_k: None,
792 stop_sequences: None,
793 tools: None,
794 tool_choice: None,
795 thinking: Some(ThinkingConfig::Enabled {
796 budget_tokens: 4096,
797 display: Some(ThinkingDisplay::Omitted),
798 }),
799 betas: None,
800 context_management: None,
801 output_config: None,
802 };
803
804 let llm_request = convert_anthropic_to_llm_request(request);
805 let overrides = llm_request
806 .anthropic_request_overrides
807 .expect("anthropic overrides");
808 assert_eq!(
809 overrides.thinking_mode,
810 AnthropicThinkingModeOverride::ManualBudget(4096)
811 );
812 assert_eq!(
813 overrides.thinking_display,
814 AnthropicThinkingDisplayOverride::Omitted
815 );
816 }
817
818 #[test]
819 fn convert_anthropic_to_llm_request_preserves_assistant_tool_calls_and_reasoning() {
820 let request = AnthropicMessagesRequest {
821 model: "claude-opus-4-7".to_string(),
822 max_tokens: 1024,
823 messages: vec![AnthropicMessage {
824 role: "assistant".to_string(),
825 content: AnthropicContent::Blocks(vec![
826 AnthropicContentBlock::Thinking {
827 thinking: "inspect files".to_string(),
828 signature: None,
829 },
830 AnthropicContentBlock::Text {
831 text: "Calling read_file".to_string(),
832 citations: None,
833 cache_control: None,
834 },
835 AnthropicContentBlock::ToolUse {
836 id: "call_123".to_string(),
837 name: "read_file".to_string(),
838 input: json!({"path": "src/main.rs"}),
839 },
840 ]),
841 }],
842 system: None,
843 stream: false,
844 temperature: None,
845 top_p: None,
846 top_k: None,
847 stop_sequences: None,
848 tools: None,
849 tool_choice: None,
850 thinking: None,
851 betas: None,
852 context_management: None,
853 output_config: None,
854 };
855
856 let llm_request = convert_anthropic_to_llm_request(request);
857 assert_eq!(llm_request.messages.len(), 1);
858 let message = &llm_request.messages[0];
859 assert_eq!(message.reasoning.as_deref(), Some("inspect files"));
860 assert_eq!(message.content.as_text().as_ref(), "Calling read_file");
861 assert_eq!(
862 message
863 .tool_calls
864 .as_ref()
865 .and_then(|calls| calls.first())
866 .and_then(|call| call.function.as_ref())
867 .map(|function| function.name.as_str()),
868 Some("read_file")
869 );
870 }
871
872 #[test]
873 fn convert_anthropic_to_llm_request_maps_disable_parallel_tool_use() {
874 let request = AnthropicMessagesRequest {
875 model: "claude-opus-4-7".to_string(),
876 max_tokens: 1024,
877 messages: vec![AnthropicMessage {
878 role: "user".to_string(),
879 content: AnthropicContent::Text("use one tool at a time".to_string()),
880 }],
881 system: None,
882 stream: false,
883 temperature: None,
884 top_p: None,
885 top_k: None,
886 stop_sequences: None,
887 tools: None,
888 tool_choice: Some(json!({
889 "type": "auto",
890 "disable_parallel_tool_use": true
891 })),
892 thinking: None,
893 betas: None,
894 context_management: None,
895 output_config: None,
896 };
897
898 let llm_request = convert_anthropic_to_llm_request(request);
899 assert!(matches!(llm_request.tool_choice, Some(ToolChoice::Auto)));
900 assert!(
901 llm_request
902 .parallel_tool_config
903 .as_ref()
904 .is_some_and(|config| config.disable_parallel_tool_use)
905 );
906 }
907
908 #[test]
909 fn anthropic_content_block_thinking_uses_anthropic_wire_field() {
910 let block = AnthropicContentBlock::Thinking {
911 thinking: "plan".to_string(),
912 signature: None,
913 };
914
915 let serialized = serde_json::to_value(block).expect("serialize thinking block");
916 assert_eq!(serialized["type"], "thinking");
917 assert_eq!(serialized["thinking"], "plan");
918 assert!(serialized.get("text").is_none());
919 }
920
921 #[test]
922 fn anthropic_content_delta_thinking_uses_anthropic_wire_field() {
923 let delta = AnthropicContentDelta::ThinkingDelta {
924 thinking: "draft".to_string(),
925 };
926
927 let serialized = serde_json::to_value(delta).expect("serialize thinking delta");
928 assert_eq!(serialized["type"], "thinking_delta");
929 assert_eq!(serialized["thinking"], "draft");
930 assert!(serialized.get("text").is_none());
931 }
932
933 #[test]
934 fn convert_llm_to_anthropic_response_preserves_reasoning_and_model() {
935 let response = crate::llm::provider::LLMResponse {
936 content: Some("Done".to_string()),
937 model: "claude-opus-4-7".to_string(),
938 reasoning: Some("inspect files".to_string()),
939 ..Default::default()
940 };
941
942 let anthropic = convert_llm_to_anthropic_response(response);
943 assert_eq!(anthropic.model, "claude-opus-4-7");
944 assert!(matches!(
945 anthropic.content.first(),
946 Some(AnthropicContentBlock::Thinking { thinking, .. }) if thinking == "inspect files"
947 ));
948 assert!(matches!(
949 anthropic.content.get(1),
950 Some(AnthropicContentBlock::Text { text, .. }) if text == "Done"
951 ));
952 }
953
954 #[test]
955 fn convert_llm_to_anthropic_response_preserves_reasoning_signature_details() {
956 let response = crate::llm::provider::LLMResponse {
957 model: "claude-opus-4-7".to_string(),
958 reasoning_details: Some(vec![
959 json!({
960 "type": "thinking",
961 "thinking": "",
962 "signature": "sig_123",
963 })
964 .to_string(),
965 json!({
966 "type": "redacted_thinking",
967 "data": "encrypted",
968 })
969 .to_string(),
970 ]),
971 ..Default::default()
972 };
973
974 let anthropic = convert_llm_to_anthropic_response(response);
975 assert!(matches!(
976 anthropic.content.first(),
977 Some(AnthropicContentBlock::Thinking { thinking, signature })
978 if thinking.is_empty() && signature.as_deref() == Some("sig_123")
979 ));
980 assert!(matches!(
981 anthropic.content.get(1),
982 Some(AnthropicContentBlock::RedactedThinking { data }) if data == "encrypted"
983 ));
984 }
985}