Skip to main content

vtcode_core/llm/providers/anthropic/
api.rs

1//! Anthropic API compatibility server
2//!
3//! Provides compatibility with the Anthropic Messages API to help connect existing
4//! applications to VT Code, including tools like Claude Code.
5
6use 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/// Server state containing shared resources
27#[derive(Clone)]
28pub struct AnthropicApiServerState {
29    /// The LLM provider to use for requests
30    pub provider: Arc<dyn LLMProvider>,
31    /// Model name to use
32    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
41/// Create the Anthropic API router
42pub 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
105/// Handle messages endpoint
106pub 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        // Handle streaming response
119        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        // Create a channel to bridge the stream
127        let (tx, rx) = tokio::sync::mpsc::channel(100);
128
129        // Spawn a task to convert the stream
130        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            // Send message_start event
137            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; // Exit the stream
297                            }
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        // Handle non-streaming response
320        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-sonnet-4-6".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-sonnet-4-6".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-sonnet-4-6".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-sonnet-4-6".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-sonnet-4-6".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-sonnet-4-6".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-sonnet-4-6".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_maps_thinking_display_effort_and_task_budget() {
660        let request = AnthropicMessagesRequest {
661            model: "claude-sonnet-4-6".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: Some(ThinkingConfig::Adaptive {
676                display: Some(ThinkingDisplay::Summarized),
677            }),
678            betas: None,
679            context_management: None,
680            output_config: Some(AnthropicOutputConfig {
681                effort: Some("medium".to_string()),
682                task_budget: Some(AnthropicTaskBudget {
683                    budget_type: "tokens".to_string(),
684                    total: 64_000,
685                }),
686                format: None,
687            }),
688        };
689
690        let llm_request = convert_anthropic_to_llm_request(request);
691        let overrides = llm_request
692            .anthropic_request_overrides
693            .expect("anthropic overrides");
694        assert_eq!(
695            overrides.thinking_mode,
696            AnthropicThinkingModeOverride::Adaptive
697        );
698        assert_eq!(
699            overrides.thinking_display,
700            AnthropicThinkingDisplayOverride::Summarized
701        );
702        assert_eq!(
703            overrides.effort,
704            AnthropicOptionalStringOverride::Explicit("medium".to_string())
705        );
706        assert_eq!(
707            overrides.task_budget_tokens,
708            AnthropicOptionalU32Override::Explicit(64_000)
709        );
710    }
711
712    #[test]
713    fn convert_anthropic_to_llm_request_maps_manual_budget_thinking_mode() {
714        let request = AnthropicMessagesRequest {
715            model: "claude-sonnet-4-6".to_string(),
716            max_tokens: 1024,
717            messages: vec![AnthropicMessage {
718                role: "user".to_string(),
719                content: AnthropicContent::Text("hello".to_string()),
720            }],
721            system: None,
722            stream: false,
723            temperature: None,
724            top_p: None,
725            top_k: None,
726            stop_sequences: None,
727            tools: None,
728            tool_choice: None,
729            thinking: Some(ThinkingConfig::Enabled {
730                budget_tokens: 4096,
731                display: Some(ThinkingDisplay::Omitted),
732            }),
733            betas: None,
734            context_management: None,
735            output_config: None,
736        };
737
738        let llm_request = convert_anthropic_to_llm_request(request);
739        let overrides = llm_request
740            .anthropic_request_overrides
741            .expect("anthropic overrides");
742        assert_eq!(
743            overrides.thinking_mode,
744            AnthropicThinkingModeOverride::ManualBudget(4096)
745        );
746        assert_eq!(
747            overrides.thinking_display,
748            AnthropicThinkingDisplayOverride::Omitted
749        );
750    }
751
752    #[test]
753    fn convert_anthropic_to_llm_request_preserves_assistant_tool_calls_and_reasoning() {
754        let request = AnthropicMessagesRequest {
755            model: "claude-sonnet-4-6".to_string(),
756            max_tokens: 1024,
757            messages: vec![AnthropicMessage {
758                role: "assistant".to_string(),
759                content: AnthropicContent::Blocks(vec![
760                    AnthropicContentBlock::Thinking {
761                        thinking: "inspect files".to_string(),
762                        signature: None,
763                    },
764                    AnthropicContentBlock::Text {
765                        text: "Calling read_file".to_string(),
766                        citations: None,
767                        cache_control: None,
768                    },
769                    AnthropicContentBlock::ToolUse {
770                        id: "call_123".to_string(),
771                        name: "read_file".to_string(),
772                        input: json!({"path": "src/main.rs"}),
773                    },
774                ]),
775            }],
776            system: None,
777            stream: false,
778            temperature: None,
779            top_p: None,
780            top_k: None,
781            stop_sequences: None,
782            tools: None,
783            tool_choice: None,
784            thinking: None,
785            betas: None,
786            context_management: None,
787            output_config: None,
788        };
789
790        let llm_request = convert_anthropic_to_llm_request(request);
791        assert_eq!(llm_request.messages.len(), 1);
792        let message = &llm_request.messages[0];
793        assert_eq!(message.reasoning.as_deref(), Some("inspect files"));
794        assert_eq!(message.content.as_text().as_ref(), "Calling read_file");
795        assert_eq!(
796            message
797                .tool_calls
798                .as_ref()
799                .and_then(|calls| calls.first())
800                .and_then(|call| call.function.as_ref())
801                .map(|function| function.name.as_str()),
802            Some("read_file")
803        );
804    }
805
806    #[test]
807    fn convert_anthropic_to_llm_request_maps_disable_parallel_tool_use() {
808        let request = AnthropicMessagesRequest {
809            model: "claude-sonnet-4-6".to_string(),
810            max_tokens: 1024,
811            messages: vec![AnthropicMessage {
812                role: "user".to_string(),
813                content: AnthropicContent::Text("use one tool at a time".to_string()),
814            }],
815            system: None,
816            stream: false,
817            temperature: None,
818            top_p: None,
819            top_k: None,
820            stop_sequences: None,
821            tools: None,
822            tool_choice: Some(json!({
823                "type": "auto",
824                "disable_parallel_tool_use": true
825            })),
826            thinking: None,
827            betas: None,
828            context_management: None,
829            output_config: None,
830        };
831
832        let llm_request = convert_anthropic_to_llm_request(request);
833        assert!(matches!(llm_request.tool_choice, Some(ToolChoice::Auto)));
834        assert!(
835            llm_request
836                .parallel_tool_config
837                .as_ref()
838                .is_some_and(|config| config.disable_parallel_tool_use)
839        );
840    }
841
842    #[test]
843    fn anthropic_content_block_thinking_uses_anthropic_wire_field() {
844        let block = AnthropicContentBlock::Thinking {
845            thinking: "plan".to_string(),
846            signature: None,
847        };
848
849        let serialized = serde_json::to_value(block).expect("serialize thinking block");
850        assert_eq!(serialized["type"], "thinking");
851        assert_eq!(serialized["thinking"], "plan");
852        assert!(serialized.get("text").is_none());
853    }
854
855    #[test]
856    fn anthropic_content_delta_thinking_uses_anthropic_wire_field() {
857        let delta = AnthropicContentDelta::ThinkingDelta {
858            thinking: "draft".to_string(),
859        };
860
861        let serialized = serde_json::to_value(delta).expect("serialize thinking delta");
862        assert_eq!(serialized["type"], "thinking_delta");
863        assert_eq!(serialized["thinking"], "draft");
864        assert!(serialized.get("text").is_none());
865    }
866
867    #[test]
868    fn convert_llm_to_anthropic_response_preserves_reasoning_and_model() {
869        let response = crate::llm::provider::LLMResponse {
870            content: Some("Done".to_string()),
871            model: "claude-sonnet-4-6".to_string(),
872            reasoning: Some("inspect files".to_string()),
873            ..Default::default()
874        };
875
876        let anthropic = convert_llm_to_anthropic_response(response);
877        assert_eq!(anthropic.model, "claude-sonnet-4-6");
878        assert!(matches!(
879            anthropic.content.first(),
880            Some(AnthropicContentBlock::Thinking { thinking, .. }) if thinking == "inspect files"
881        ));
882        assert!(matches!(
883            anthropic.content.get(1),
884            Some(AnthropicContentBlock::Text { text, .. }) if text == "Done"
885        ));
886    }
887
888    #[test]
889    fn convert_llm_to_anthropic_response_preserves_reasoning_signature_details() {
890        let response = crate::llm::provider::LLMResponse {
891            model: "claude-sonnet-4-6".to_string(),
892            reasoning_details: Some(vec![
893                json!({
894                    "type": "thinking",
895                    "thinking": "",
896                    "signature": "sig_123",
897                })
898                .to_string(),
899                json!({
900                    "type": "redacted_thinking",
901                    "data": "encrypted",
902                })
903                .to_string(),
904            ]),
905            ..Default::default()
906        };
907
908        let anthropic = convert_llm_to_anthropic_response(response);
909        assert!(matches!(
910            anthropic.content.first(),
911            Some(AnthropicContentBlock::Thinking { thinking, signature })
912                if thinking.is_empty() && signature.as_deref() == Some("sig_123")
913        ));
914        assert!(matches!(
915            anthropic.content.get(1),
916            Some(AnthropicContentBlock::RedactedThinking { data }) if data == "encrypted"
917        ));
918    }
919}