Skip to main content

vtcode_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::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/// Server state containing shared resources
27#[derive(Clone)]
28pub struct AnthropicApiServerState {
29    /// The LLM provider to use for requests
30    provider: Arc<dyn LLMProvider>,
31    /// Model name to use
32    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(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
90/// Handle messages endpoint
91async 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        // Handle streaming response
104        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        // Create a channel to bridge the stream
112        let (tx, rx) = tokio::sync::mpsc::channel(100);
113
114        // Spawn a task to convert the stream
115        tokio::spawn(async move {
116            // LLMStream is already Pin<Box<dyn Stream>>; Pin<Box<T>> is itself
117            // Unpin, so no re-pinning is needed to call StreamExt::next.
118            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            // Send message_start event
124            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; // Exit the stream
268                            }
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        // Handle non-streaming response
291        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}