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-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}