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