1use 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#[derive(Clone)]
28pub struct AnthropicApiServerState {
29 provider: Arc<dyn LLMProvider>,
31 model: String,
33}
34
35impl AnthropicApiServerState {
36 pub fn new(provider: Arc<dyn LLMProvider>, model: String) -> Self {
37 Self { provider, model }
38 }
39}
40
41pub fn create_router(state: AnthropicApiServerState) -> Router {
43 Router::new()
44 .route("/v1/messages", axum::routing::post(messages_handler))
45 .with_state(state)
46 .layer(CorsLayer::permissive())
47}
48
49fn merge_header_betas(request: &mut AnthropicMessagesRequest, headers: &HeaderMap) {
50 let Some(header_betas) = headers
51 .get("anthropic-beta")
52 .and_then(|value| value.to_str().ok())
53 .map(|value| {
54 value
55 .split(',')
56 .map(str::trim)
57 .filter(|beta| !beta.is_empty())
58 .map(str::to_string)
59 .collect::<Vec<_>>()
60 })
61 .filter(|betas| !betas.is_empty())
62 else {
63 return;
64 };
65
66 let request_betas = request.betas.get_or_insert_with(Vec::new);
67 for beta in header_betas {
68 if !request_betas.contains(&beta) {
69 request_betas.push(beta);
70 }
71 }
72}
73
74async fn send_stream_event(tx: &AnthropicSseSender, event: AnthropicStreamEvent) -> bool {
75 tx.send(Event::default().json_data(event)).await.is_ok()
76}
77
78async fn send_content_block_start(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
90async 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 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 let (tx, rx) = tokio::sync::mpsc::channel(100);
116
117 tokio::spawn(async move {
119 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 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; }
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 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 #[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 #[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}