1use std::collections::HashMap;
23use std::time::Duration;
24
25use async_trait::async_trait;
26use futures::StreamExt;
27use reqwest::Client;
28use serde::Deserialize;
29use serde_json::{Value, json};
30
31use crate::constants::MAX_RESPONSE_CHARS;
32use crate::models::ModelCapabilities;
33use crate::models::config::ModelConfig;
34use crate::models::error::{BackendError, ModelError, Result};
35use crate::models::reasoning::{
36 ReasoningCapability, ReasoningChunk, ReasoningLevel, nearest_effort,
37};
38use crate::models::stream::{StreamCallback, StreamEvent};
39use crate::models::tool_call::{FunctionCall, ToolCall};
40use crate::models::traits::Model;
41
42use super::ModelLimits;
43use super::output_budget::{OutputBudgetInputs, OutputCapMode, resolve_output_budget};
44use crate::models::types::{
45 ChatMessage, FinishReason, MessageAudience, MessageRole, ModelResponse, ProviderContinuation,
46 TokenUsage,
47};
48use crate::utils::drain_sse_events;
49
50const TRUNCATION_MARKER: &str = "\n\n[TRUNCATED: response exceeded size limit]";
51const ANTHROPIC_VERSION: &str = "2023-06-01";
54
55fn push_capped(buf: &mut String, chunk: &str, truncated: &mut bool, cap: usize) {
59 if *truncated {
60 return;
61 }
62 buf.push_str(chunk);
63 if buf.len() > cap {
64 let end = buf.floor_char_boundary(cap);
65 buf.truncate(end);
66 buf.push_str(TRUNCATION_MARKER);
67 *truncated = true;
68 }
69}
70
71fn push_tool_arg(buf: &mut String, frag: &str) {
78 let cap = crate::constants::MAX_TOOL_ARG_BYTES;
79 if buf.len() >= cap {
80 return;
81 }
82 if buf.len() + frag.len() <= cap {
83 buf.push_str(frag);
84 } else {
85 let room = cap - buf.len();
86 let end = frag.floor_char_boundary(room);
87 buf.push_str(&frag[..end]);
88 }
89}
90
91fn map_anthropic_stop_reason(s: &str) -> FinishReason {
93 match s {
94 "end_turn" | "stop_sequence" => FinishReason::Stop,
95 "tool_use" => FinishReason::ToolUse,
96 "max_tokens" => FinishReason::Length,
97 "refusal" => FinishReason::ContentFilter,
98 other => FinishReason::Other(other.to_string()),
99 }
100}
101
102fn stream_closed_abnormally(saw_message_stop: bool, stop_reason: Option<&FinishReason>) -> bool {
112 !saw_message_stop && stop_reason.is_none()
113}
114
115fn finalize_block(
120 acc: BlockAccumulator,
121 text_acc: &mut String,
122 thinking_acc: &mut String,
123 signature_acc: &mut Option<String>,
124 tool_calls_done: &mut Vec<ToolCall>,
125 callback: &StreamCallback,
126) {
127 match acc {
128 BlockAccumulator::Text(s) => text_acc.push_str(&s),
129 BlockAccumulator::Thinking { content, signature } => {
130 thinking_acc.push_str(&content);
131 if signature.is_some() {
132 *signature_acc = signature;
133 }
134 },
135 BlockAccumulator::ToolUse {
136 id,
137 name,
138 input_buf,
139 } => {
140 let arguments: Value = if input_buf.is_empty() {
141 json!({})
142 } else {
143 match serde_json::from_str(&input_buf) {
144 Ok(v) => v,
145 Err(_) => Value::String(input_buf),
146 }
147 };
148 let tc = ToolCall {
149 id: if id.is_empty() { None } else { Some(id) },
150 function: FunctionCall { name, arguments },
151 };
152 callback(StreamEvent::ToolCall(tc.clone()));
153 tool_calls_done.push(tc);
154 },
155 BlockAccumulator::Other => {},
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161enum ThinkingFormat {
162 Adaptive,
165 Legacy,
168}
169
170fn thinking_format_for(model: &str) -> ThinkingFormat {
178 match crate::models::catalog::lookup(model).thinking {
179 crate::models::catalog::ThinkingShape::AnthropicAdaptive => ThinkingFormat::Adaptive,
180 _ => ThinkingFormat::Legacy,
181 }
182}
183
184fn legacy_budget_for(level: ReasoningLevel, max_tokens: usize) -> Option<u32> {
193 let proposed: u32 = match level {
194 ReasoningLevel::None => return None,
195 ReasoningLevel::Minimal | ReasoningLevel::Low => 2048,
196 ReasoningLevel::Medium => 4096,
197 ReasoningLevel::High => 16000,
198 ReasoningLevel::XHigh => 24000,
200 ReasoningLevel::Max => 32000,
201 };
202 if max_tokens <= 1024 {
206 return None;
207 }
208 let ceiling = max_tokens.saturating_sub(1024) as u32;
209 Some(proposed.min(ceiling).max(1024))
210}
211
212const ANTHROPIC_FALLBACK_MAX_OUTPUT_TOKENS: usize = 8_192;
218
219fn estimate_prompt_tokens(messages: &[ChatMessage], system: Option<&str>) -> usize {
222 let chars =
223 messages.iter().map(|m| m.content.len()).sum::<usize>() + system.map_or(0, str::len);
224 chars / 4
225}
226
227fn adaptive_effort_for(level: ReasoningLevel, model: &str) -> Option<&'static str> {
239 use crate::models::catalog::EffortCeiling;
240 let ceiling = crate::models::catalog::lookup(model).effort_ceiling;
241 if ceiling == EffortCeiling::None {
244 return None;
245 }
246 match level {
247 ReasoningLevel::None => None,
248 ReasoningLevel::Minimal | ReasoningLevel::Low => Some("low"),
249 ReasoningLevel::Medium => Some("medium"),
250 ReasoningLevel::High => Some("high"),
251 ReasoningLevel::XHigh => {
252 if ceiling >= EffortCeiling::XHigh {
253 Some("xhigh")
254 } else {
255 Some("high")
256 }
257 },
258 ReasoningLevel::Max => {
259 if ceiling >= EffortCeiling::Max {
260 Some("max")
261 } else {
262 Some("high")
263 }
264 },
265 }
266}
267
268fn to_anthropic_tools(openai_tools: &[&Value]) -> Vec<Value> {
276 openai_tools
277 .iter()
278 .filter_map(|tool| {
279 let function = tool.get("function")?;
280 let name = function.get("name")?.as_str()?;
281 let description = function
282 .get("description")
283 .and_then(|d| d.as_str())
284 .unwrap_or("");
285 let input_schema = function.get("parameters").cloned().unwrap_or(json!({
286 "type": "object",
287 "properties": {}
288 }));
289 Some(json!({
290 "type": "custom",
291 "name": name,
292 "description": description,
293 "input_schema": input_schema,
294 }))
295 })
296 .collect()
297}
298
299fn push_system_reminder(out: &mut Vec<Value>, text: &str) {
308 out.push(json!({
309 "role": "user",
310 "content": [{
311 "type": "text",
312 "text": format!("<system-reminder>\n{text}\n</system-reminder>"),
313 }],
314 }));
315}
316
317fn content_blocks(content: &Value) -> Vec<Value> {
324 match content {
325 Value::Array(blocks) => blocks.clone(),
326 Value::String(text) if !text.is_empty() => {
327 vec![json!({"type": "text", "text": text})]
328 },
329 _ => Vec::new(),
330 }
331}
332
333fn coalesce_consecutive_roles(msgs: Vec<Value>) -> Vec<Value> {
349 let mut out: Vec<Value> = Vec::with_capacity(msgs.len());
350 for msg in msgs {
351 let same_role = out.last().is_some_and(|prev| prev["role"] == msg["role"]);
352 let Some(prev) = out.last_mut().filter(|_| same_role) else {
353 out.push(msg);
354 continue;
355 };
356 let incoming = content_blocks(&msg["content"]);
357 if incoming.is_empty() {
358 continue;
359 }
360 let mut blocks = content_blocks(&prev["content"]);
361 blocks.extend(incoming);
362 let lead = if msg["role"] == "user" {
365 "tool_result"
366 } else {
367 "thinking"
368 };
369 let (mut leading, rest): (Vec<Value>, Vec<Value>) =
370 blocks.into_iter().partition(|b| b["type"] == lead);
371 leading.extend(rest);
372 prev["content"] = Value::Array(leading);
373 }
374 out
375}
376
377fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<Value>) {
394 let mut system: Option<String> = None;
395 let mut out: Vec<Value> = Vec::new();
396
397 let mut i = 0;
398 while i < messages.len() {
399 let msg = &messages[i];
400 match msg.role {
401 MessageRole::System
402 if msg.kind.audience() == MessageAudience::ModelDirected
403 && !msg.content.is_empty() =>
404 {
405 push_system_reminder(&mut out, &msg.content);
414 i += 1;
415 },
416 MessageRole::System => {
417 if system.is_none() {
420 system = Some(msg.content.clone());
421 }
422 i += 1;
423 },
424 MessageRole::User => {
425 let mut content_blocks: Vec<Value> = Vec::new();
426 if !msg.content.is_empty() {
427 content_blocks.push(json!({
428 "type": "text",
429 "text": msg.content,
430 }));
431 }
432 if let Some(ref images) = msg.images {
434 for data in images {
435 content_blocks.push(json!({
439 "type": "image",
440 "source": {
441 "type": "base64",
442 "media_type": "image/png",
443 "data": data,
444 },
445 }));
446 }
447 }
448 let content = if content_blocks.len() == 1 && content_blocks[0]["type"] == "text" {
449 content_blocks[0]["text"].clone()
453 } else if content_blocks.is_empty() {
454 json!("")
458 } else {
459 json!(content_blocks)
460 };
461 out.push(json!({"role": "user", "content": content}));
462 i += 1;
463 },
464 MessageRole::Assistant => {
465 let mut content_blocks: Vec<Value> = Vec::new();
466 if let (Some(thinking), Some(sig)) = (
474 &msg.thinking,
475 msg.provider_continuation
476 .as_ref()
477 .and_then(ProviderContinuation::anthropic_signature),
478 ) && !thinking.is_empty()
479 && !sig.is_empty()
480 {
481 content_blocks.push(json!({
482 "type": "thinking",
483 "thinking": thinking,
484 "signature": sig,
485 }));
486 } else if msg.thinking.as_deref().is_some_and(|t| !t.is_empty()) {
487 tracing::debug!(
488 "dropping assistant thinking block that lacks a signature (would 400)",
489 );
490 }
491 if !msg.content.is_empty() {
492 content_blocks.push(json!({
493 "type": "text",
494 "text": msg.content,
495 }));
496 }
497 if let Some(ref tool_calls) = msg.tool_calls {
498 for tc in tool_calls {
499 content_blocks.push(json!({
500 "type": "tool_use",
501 "id": tc.id.clone().unwrap_or_default(),
502 "name": tc.function.name,
503 "input": tc.function.arguments,
504 }));
505 }
506 }
507 if content_blocks.is_empty() {
508 i += 1;
513 continue;
514 }
515 out.push(json!({"role": "assistant", "content": content_blocks}));
516 i += 1;
517 },
518 MessageRole::Tool => {
519 let mut tool_blocks: Vec<Value> = Vec::new();
522 while i < messages.len() && messages[i].role == MessageRole::Tool {
523 let t = &messages[i];
524 let tool_use_id = t.tool_call_id.clone().unwrap_or_default();
525 tool_blocks.push(json!({
526 "type": "tool_result",
527 "tool_use_id": tool_use_id,
528 "content": t.content,
529 }));
530 i += 1;
531 }
532 out.push(json!({"role": "user", "content": tool_blocks}));
533 },
534 }
535 }
536
537 (system, coalesce_consecutive_roles(out))
538}
539
540pub struct AnthropicAdapter {
542 client: Client,
543 api_key: String,
544 base_url: String,
545 model_name: String,
546 capabilities: ModelCapabilities,
547}
548
549impl AnthropicAdapter {
550 pub fn new(api_key: String, model_name: String, base_url: String) -> Result<Self> {
553 let client = Client::builder()
554 .pool_max_idle_per_host(10)
555 .pool_idle_timeout(Duration::from_secs(90))
556 .tcp_keepalive(Duration::from_secs(60))
557 .connect_timeout(Duration::from_secs(10))
558 .build()
559 .map_err(|e| {
560 ModelError::Backend(BackendError::ConnectionFailed {
561 backend: "anthropic".to_string(),
562 url: base_url.clone(),
563 reason: e.to_string(),
564 })
565 })?;
566
567 let capabilities = ModelCapabilities {
575 supports_tools: true,
576 supports_vision: true,
577 supports_reasoning: ReasoningCapability::Levels(vec![
578 ReasoningLevel::None,
579 ReasoningLevel::Low,
580 ReasoningLevel::Medium,
581 ReasoningLevel::High,
582 ReasoningLevel::Max,
583 ReasoningLevel::XHigh,
584 ]),
585 max_context_tokens: None,
589 max_output_tokens: None,
590 };
591
592 Ok(Self {
593 client,
594 api_key,
595 base_url,
596 model_name,
597 capabilities,
598 })
599 }
600
601 fn build_request_body(&self, messages: &[ChatMessage], config: &ModelConfig) -> Value {
603 let (system_from_msgs, anthropic_messages) = convert_messages(messages);
604 let system = config.system_prompt.clone().or(system_from_msgs);
608
609 let max_tokens = resolve_output_budget(
618 &OutputBudgetInputs {
619 requested_cap: config.max_tokens,
620 window: config.resolved_context_window,
621 prompt_estimate: estimate_prompt_tokens(messages, system.as_deref()),
622 provider_max_output: Some(
623 config
624 .resolved_max_output
625 .unwrap_or(ANTHROPIC_FALLBACK_MAX_OUTPUT_TOKENS),
626 ),
627 margin: 1_024,
629 floor: 1,
630 },
631 OutputCapMode::Required,
632 )
633 .expect("Required mode always resolves a concrete max_tokens");
634
635 let mut body = json!({
636 "model": self.model_name,
637 "messages": anthropic_messages,
638 "max_tokens": max_tokens,
639 "stream": true,
640 });
641
642 if let Some(s) = system
656 && !s.is_empty()
657 {
658 let mut blocks = vec![json!({
659 "type": "text",
660 "text": s,
661 "cache_control": {"type": "ephemeral"},
662 })];
663 if let Some(suffix) = config.dynamic_system_suffix.as_deref()
664 && !suffix.is_empty()
665 {
666 blocks.push(json!({
667 "type": "text",
668 "text": suffix,
669 "cache_control": {"type": "ephemeral"},
670 }));
671 }
672 body["system"] = json!(blocks);
673 }
674
675 if crate::models::catalog::lookup(&self.model_name).supports_temperature {
683 let temp = config.temperature.clamp(0.0, 1.0);
684 body["temperature"] = json!(temp);
685 }
686
687 let registered: Vec<&Value> = config.tools.iter().collect();
690 let mut anthropic_tools = to_anthropic_tools(®istered);
691 if !anthropic_tools.is_empty() {
692 if let Some(last) = anthropic_tools.last_mut()
698 && let Some(obj) = last.as_object_mut()
699 {
700 obj.insert("cache_control".to_string(), json!({"type": "ephemeral"}));
701 }
702 body["tools"] = json!(anthropic_tools);
703 }
704
705 let effective_reasoning = match &self.capabilities.supports_reasoning {
709 ReasoningCapability::Levels(supported) => {
710 nearest_effort(config.reasoning, supported).unwrap_or(ReasoningLevel::None)
711 },
712 _ => config.reasoning,
713 };
714
715 if let Some(effort) = adaptive_effort_for(effective_reasoning, &self.model_name) {
722 body["output_config"] = json!({"effort": effort});
723 }
724
725 if let Some(schema) = &config.output_schema {
732 body["output_config"]["format"] = json!({
733 "type": "json_schema",
734 "schema": schema,
735 });
736 }
737
738 match thinking_format_for(&self.model_name) {
740 ThinkingFormat::Adaptive => {
741 if effective_reasoning != ReasoningLevel::None {
749 let display = if config.hide_reasoning_trace {
750 "omitted"
751 } else {
752 "summarized"
753 };
754 body["thinking"] = json!({
755 "type": "adaptive",
756 "display": display,
757 });
758 }
759 },
760 ThinkingFormat::Legacy => {
761 if let Some(budget) = legacy_budget_for(effective_reasoning, max_tokens) {
762 body["thinking"] = json!({
763 "type": "enabled",
764 "budget_tokens": budget,
765 });
766 }
767 },
768 }
769
770 body
771 }
772
773 async fn send_chat(&self, body: &Value) -> Result<reqwest::Response> {
777 let url = format!("{}/messages", self.base_url.trim_end_matches('/'));
778 crate::models::retry::retry_transient_http(|| async {
779 self.client
780 .post(&url)
781 .header("x-api-key", &self.api_key)
782 .header("anthropic-version", ANTHROPIC_VERSION)
783 .header("content-type", "application/json")
784 .json(body)
785 .send()
786 .await
787 .map_err(|e| {
788 ModelError::Backend(BackendError::ConnectionFailed {
789 backend: "anthropic".to_string(),
790 url: url.clone(),
791 reason: e.to_string(),
792 })
793 })
794 })
795 .await
796 }
797
798 pub async fn fetch_model_limits(&self) -> Result<ModelLimits> {
804 let url = format!(
805 "{}/models/{}",
806 self.base_url.trim_end_matches('/'),
807 self.model_name
808 );
809 let response = self
810 .client
811 .get(&url)
812 .header("x-api-key", &self.api_key)
813 .header("anthropic-version", ANTHROPIC_VERSION)
814 .send()
815 .await
816 .map_err(|e| {
817 ModelError::Backend(BackendError::ConnectionFailed {
818 backend: "anthropic".to_string(),
819 url: url.clone(),
820 reason: e.to_string(),
821 })
822 })?;
823 if response.status() == reqwest::StatusCode::NOT_FOUND {
824 return Ok(ModelLimits::default());
825 }
826 if !response.status().is_success() {
827 return Err(http_error_from_response(response).await);
828 }
829 let info: AnthropicModelInfo =
830 response.json().await.map_err(|e| ModelError::ParseError {
831 message: format!("Failed to parse Anthropic model info: {}", e),
832 raw: None,
833 })?;
834 Ok(info.into())
835 }
836
837 async fn decode_non_streaming(&self, response: reqwest::Response) -> Result<ModelResponse> {
843 if !response.status().is_success() {
844 return Err(http_error_from_response(response).await);
845 }
846
847 let json: AnthropicResponse =
848 response.json().await.map_err(|e| ModelError::ParseError {
849 message: format!("Failed to parse Anthropic response: {}", e),
850 raw: None,
851 })?;
852
853 let mut text_acc = String::new();
854 let mut thinking_acc = String::new();
855 let mut signature: Option<String> = None;
856 let mut tool_calls: Vec<ToolCall> = Vec::new();
857
858 for block in json.content {
859 match block {
860 ContentBlockOut::Text { text } => text_acc.push_str(&text),
861 ContentBlockOut::Thinking {
862 thinking,
863 signature: sig,
864 } => {
865 thinking_acc.push_str(&thinking);
866 if sig.is_some() {
867 signature = sig;
868 }
869 },
870 ContentBlockOut::ToolUse { id, name, input } => {
871 tool_calls.push(ToolCall {
872 id: Some(id),
873 function: FunctionCall {
874 name,
875 arguments: input,
876 },
877 });
878 },
879 ContentBlockOut::Other => {},
880 }
881 }
882
883 let prompt_tokens = json.usage.input_tokens.unwrap_or(0);
887 let completion_tokens = json.usage.output_tokens.unwrap_or(0);
888 let cache_creation = json.usage.cache_creation_input_tokens.unwrap_or(0);
889 let cache_read = json.usage.cache_read_input_tokens.unwrap_or(0);
890 let usage = TokenUsage::provider(prompt_tokens, completion_tokens)
891 .with_cache_creation(cache_creation)
892 .with_cached_input(cache_read);
893
894 let stop_reason = json.stop_reason.as_deref().map(map_anthropic_stop_reason);
895 if text_acc.is_empty()
896 && tool_calls.is_empty()
897 && stop_reason == Some(FinishReason::ContentFilter)
898 {
899 return Err(ModelError::Backend(BackendError::ProviderError {
900 provider: "anthropic".to_string(),
901 code: Some("refusal".to_string()),
902 message: "Anthropic returned no content (refusal / content filter)".to_string(),
903 debug: crate::models::error::ResponseDebugContext::default(),
904 }));
905 }
906
907 Ok(ModelResponse {
908 content: text_acc,
909 usage: Some(usage),
910 model_name: self.model_name.clone(),
911 stop_reason,
912 thinking: if thinking_acc.is_empty() {
913 None
914 } else {
915 Some(thinking_acc)
916 },
917 tool_calls: if tool_calls.is_empty() {
918 None
919 } else {
920 Some(tool_calls)
921 },
922 provider_continuation: signature
923 .map(|signature| ProviderContinuation::Anthropic { signature }),
924 })
925 }
926
927 async fn handle_stream(
930 &self,
931 response: reqwest::Response,
932 callback: StreamCallback,
933 hide_reasoning_trace: bool,
934 ) -> Result<ModelResponse> {
935 if !response.status().is_success() {
936 return Err(http_error_from_response(response).await);
937 }
938
939 let mut stream = response.bytes_stream();
940 let mut buf: Vec<u8> = Vec::new();
941
942 let mut text_acc = String::new();
943 let mut thinking_acc = String::new();
944 let mut signature_acc: Option<String> = None;
945 let mut tool_calls_done: Vec<ToolCall> = Vec::new();
946 let mut truncated = false;
947 let mut prompt_tokens: usize = 0;
948 let mut completion_tokens: usize = 0;
949 let mut cache_creation_tokens: usize = 0;
950 let mut cache_read_tokens: usize = 0;
951 let mut stop_reason: Option<FinishReason> = None;
952 let mut saw_message_stop = false;
956 let mut blocks: HashMap<usize, BlockAccumulator> = HashMap::new();
960
961 'stream: while let Some(chunk_result) = stream.next().await {
962 let chunk = chunk_result.map_err(|e| ModelError::StreamError(e.to_string()))?;
963 if buf.len() > crate::constants::MAX_SSE_BUFFER_BYTES {
969 return Err(ModelError::StreamError(format!(
970 "SSE stream exceeded {} byte reassembly cap without a complete event",
971 crate::constants::MAX_SSE_BUFFER_BYTES
972 )));
973 }
974 buf.extend_from_slice(&chunk);
975
976 for payload in drain_sse_events(&mut buf) {
977 let parsed: Value = match serde_json::from_str(&payload) {
978 Ok(v) => v,
979 Err(e) => {
980 return Err(ModelError::ParseError {
981 message: format!("Failed to parse Anthropic stream chunk: {}", e),
982 raw: Some(payload),
983 });
984 },
985 };
986 let event_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or("");
987
988 match event_type {
989 "message_start" => {
990 if let Some(input) = parsed
991 .pointer("/message/usage/input_tokens")
992 .and_then(|v| v.as_u64())
993 {
994 prompt_tokens = input as usize;
995 }
996 if let Some(cache_creation) = parsed
997 .pointer("/message/usage/cache_creation_input_tokens")
998 .and_then(|v| v.as_u64())
999 {
1000 cache_creation_tokens = cache_creation as usize;
1001 }
1002 if let Some(cache_read) = parsed
1003 .pointer("/message/usage/cache_read_input_tokens")
1004 .and_then(|v| v.as_u64())
1005 {
1006 cache_read_tokens = cache_read as usize;
1007 }
1008 },
1009 "content_block_start" => {
1010 let index =
1011 parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
1012 let block = parsed.get("content_block");
1013 let block_type = block
1014 .and_then(|b| b.get("type"))
1015 .and_then(|t| t.as_str())
1016 .unwrap_or("");
1017 let acc = match block_type {
1018 "text" => BlockAccumulator::Text(String::new()),
1019 "thinking" => BlockAccumulator::Thinking {
1020 content: String::new(),
1021 signature: None,
1022 },
1023 "tool_use" => {
1024 let id = block
1025 .and_then(|b| b.get("id"))
1026 .and_then(|v| v.as_str())
1027 .unwrap_or("")
1028 .to_string();
1029 let name = block
1030 .and_then(|b| b.get("name"))
1031 .and_then(|v| v.as_str())
1032 .unwrap_or("")
1033 .to_string();
1034 BlockAccumulator::ToolUse {
1035 id,
1036 name,
1037 input_buf: String::new(),
1038 }
1039 },
1040 _ => BlockAccumulator::Other,
1043 };
1044 blocks.insert(index, acc);
1045 },
1046 "content_block_delta" => {
1047 let index =
1048 parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
1049 let delta = parsed.get("delta");
1050 let delta_type = delta
1051 .and_then(|d| d.get("type"))
1052 .and_then(|t| t.as_str())
1053 .unwrap_or("");
1054 let Some(acc) = blocks.get_mut(&index) else {
1055 continue;
1056 };
1057 match (acc, delta_type) {
1058 (BlockAccumulator::Text(buf_s), "text_delta") => {
1059 let text = delta
1060 .and_then(|d| d.get("text"))
1061 .and_then(|v| v.as_str())
1062 .unwrap_or("");
1063 if !text.is_empty() && !truncated {
1064 callback(StreamEvent::Text(text.to_string()));
1065 push_capped(buf_s, text, &mut truncated, MAX_RESPONSE_CHARS);
1066 }
1067 },
1068 (
1069 BlockAccumulator::Thinking { content, signature },
1070 "thinking_delta",
1071 ) => {
1072 let text = delta
1073 .and_then(|d| d.get("thinking"))
1074 .and_then(|v| v.as_str())
1075 .unwrap_or("");
1076 if !text.is_empty() && !truncated {
1077 if !hide_reasoning_trace {
1078 callback(StreamEvent::Reasoning(ReasoningChunk {
1087 text: text.to_string(),
1088 signature: signature.clone(),
1089 }));
1090 }
1091 push_capped(content, text, &mut truncated, MAX_RESPONSE_CHARS);
1092 }
1093 },
1094 (BlockAccumulator::Thinking { signature, .. }, "signature_delta") => {
1095 let sig = delta
1096 .and_then(|d| d.get("signature"))
1097 .and_then(|v| v.as_str())
1098 .unwrap_or("");
1099 if !sig.is_empty() {
1100 *signature = Some(sig.to_string());
1101 }
1102 },
1103 (BlockAccumulator::ToolUse { input_buf, .. }, "input_json_delta") => {
1104 let frag = delta
1105 .and_then(|d| d.get("partial_json"))
1106 .and_then(|v| v.as_str())
1107 .unwrap_or("");
1108 push_tool_arg(input_buf, frag);
1109 },
1110 _ => {
1111 },
1114 }
1115 },
1116 "content_block_stop" => {
1117 let index =
1118 parsed.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
1119 if let Some(acc) = blocks.remove(&index) {
1120 finalize_block(
1121 acc,
1122 &mut text_acc,
1123 &mut thinking_acc,
1124 &mut signature_acc,
1125 &mut tool_calls_done,
1126 &callback,
1127 );
1128 }
1129 },
1130 "message_delta" => {
1131 if let Some(out) = parsed
1133 .pointer("/usage/output_tokens")
1134 .and_then(|v| v.as_u64())
1135 {
1136 completion_tokens = out as usize;
1137 }
1138 if let Some(sr) = parsed
1140 .pointer("/delta/stop_reason")
1141 .and_then(|v| v.as_str())
1142 {
1143 stop_reason = Some(map_anthropic_stop_reason(sr));
1144 }
1145 },
1146 "message_stop" => {
1147 saw_message_stop = true;
1154 break 'stream;
1155 },
1156 "error" => {
1157 let err_type = parsed
1158 .pointer("/error/type")
1159 .and_then(|v| v.as_str())
1160 .unwrap_or("api_error");
1161 let err_msg = parsed
1162 .pointer("/error/message")
1163 .and_then(|v| v.as_str())
1164 .unwrap_or("Anthropic stream error");
1165 return Err(ModelError::Backend(BackendError::ProviderError {
1166 provider: "anthropic".to_string(),
1167 code: Some(err_type.to_string()),
1168 message: err_msg.to_string(),
1169 debug: crate::models::error::ResponseDebugContext::default(),
1170 }));
1171 },
1172 "ping" | "" => {
1173 },
1175 _ => {
1176 tracing::debug!("Anthropic: unknown event type: {}", event_type);
1178 },
1179 }
1180 }
1181 }
1182
1183 if stream_closed_abnormally(saw_message_stop, stop_reason.as_ref()) {
1192 return Err(ModelError::StreamError(
1193 "Anthropic stream closed before any terminal frame (message_stop / \
1194 message_delta stop_reason); the connection was likely dropped \
1195 mid-response"
1196 .to_string(),
1197 ));
1198 }
1199
1200 if !blocks.is_empty() {
1206 tracing::warn!(
1207 open_blocks = blocks.len(),
1208 "Anthropic stream ended without message_stop; draining open blocks"
1209 );
1210 let mut remaining: Vec<(usize, BlockAccumulator)> = blocks.into_iter().collect();
1211 remaining.sort_by_key(|(idx, _)| *idx);
1212 for (_idx, acc) in remaining {
1213 finalize_block(
1214 acc,
1215 &mut text_acc,
1216 &mut thinking_acc,
1217 &mut signature_acc,
1218 &mut tool_calls_done,
1219 &callback,
1220 );
1221 }
1222 }
1223
1224 if text_acc.is_empty()
1232 && tool_calls_done.is_empty()
1233 && stop_reason == Some(FinishReason::ContentFilter)
1234 {
1235 return Err(ModelError::Backend(BackendError::ProviderError {
1236 provider: "anthropic".to_string(),
1237 code: Some("refusal".to_string()),
1238 message: "Anthropic returned no content (refusal / content filter)".to_string(),
1239 debug: crate::models::error::ResponseDebugContext::default(),
1240 }));
1241 }
1242
1243 Ok(ModelResponse {
1244 content: text_acc,
1245 usage: Some(
1246 TokenUsage::provider(prompt_tokens, completion_tokens)
1247 .with_cache_creation(cache_creation_tokens)
1248 .with_cached_input(cache_read_tokens),
1249 ),
1250 model_name: self.model_name.clone(),
1251 stop_reason,
1252 thinking: if thinking_acc.is_empty() {
1253 None
1254 } else {
1255 Some(thinking_acc)
1256 },
1257 tool_calls: if tool_calls_done.is_empty() {
1258 None
1259 } else {
1260 Some(tool_calls_done)
1261 },
1262 provider_continuation: signature_acc
1263 .map(|signature| ProviderContinuation::Anthropic { signature }),
1264 })
1265 }
1266}
1267
1268#[async_trait]
1269impl Model for AnthropicAdapter {
1270 fn name(&self) -> &str {
1271 &self.model_name
1272 }
1273
1274 fn capabilities(&self) -> &ModelCapabilities {
1275 &self.capabilities
1276 }
1277
1278 async fn list_models(&self) -> Result<Vec<String>> {
1283 Err(ModelError::Unsupported {
1284 feature: "list_models (anthropic)".to_string(),
1285 })
1286 }
1287
1288 async fn chat(
1289 &self,
1290 messages: &[ChatMessage],
1291 config: &ModelConfig,
1292 callback: Option<StreamCallback>,
1293 ) -> Result<ModelResponse> {
1294 let mut body = self.build_request_body(messages, config);
1295 let stream = callback.is_some();
1296 if !stream {
1297 body["stream"] = json!(false);
1298 }
1299 let response = self.send_chat(&body).await?;
1300 if let Some(cb) = callback {
1301 self.handle_stream(response, cb, config.hide_reasoning_trace)
1302 .await
1303 } else {
1304 self.decode_non_streaming(response).await
1305 }
1306 }
1307}
1308
1309#[derive(Debug, Default, Deserialize)]
1316struct AnthropicModelInfo {
1317 #[serde(default)]
1318 max_input_tokens: Option<usize>,
1319 #[serde(default)]
1320 max_tokens: Option<usize>,
1321}
1322
1323impl From<AnthropicModelInfo> for ModelLimits {
1324 fn from(info: AnthropicModelInfo) -> Self {
1325 ModelLimits {
1326 max_context_tokens: info.max_input_tokens,
1327 max_output_tokens: info.max_tokens,
1328 }
1329 }
1330}
1331
1332#[derive(Debug, Deserialize)]
1334struct AnthropicResponse {
1335 content: Vec<ContentBlockOut>,
1336 #[serde(default)]
1337 usage: UsageOut,
1338 #[serde(default)]
1339 stop_reason: Option<String>,
1340}
1341
1342#[derive(Debug, Default, Deserialize)]
1343struct UsageOut {
1344 #[serde(default)]
1345 input_tokens: Option<usize>,
1346 #[serde(default)]
1347 output_tokens: Option<usize>,
1348 #[serde(default)]
1349 cache_creation_input_tokens: Option<usize>,
1350 #[serde(default)]
1351 cache_read_input_tokens: Option<usize>,
1352}
1353
1354#[derive(Debug, Deserialize)]
1356#[serde(tag = "type", rename_all = "snake_case")]
1357enum ContentBlockOut {
1358 Text {
1359 text: String,
1360 },
1361 Thinking {
1362 thinking: String,
1363 #[serde(default)]
1364 signature: Option<String>,
1365 },
1366 ToolUse {
1367 id: String,
1368 name: String,
1369 input: Value,
1370 },
1371 #[serde(other)]
1376 Other,
1377}
1378
1379#[derive(Debug)]
1383enum BlockAccumulator {
1384 Text(String),
1385 Thinking {
1386 content: String,
1387 signature: Option<String>,
1388 },
1389 ToolUse {
1390 id: String,
1391 name: String,
1392 input_buf: String,
1393 },
1394 Other,
1397}
1398
1399async fn http_error_from_response(response: reqwest::Response) -> ModelError {
1401 let status = response.status().as_u16();
1402 let debug = crate::models::error::ResponseDebugContext::from_headers(response.headers());
1403 let body = response
1404 .text()
1405 .await
1406 .unwrap_or_else(|_| "Unknown error".to_string());
1407 if let Ok(parsed) = serde_json::from_str::<Value>(&body)
1410 && let (Some(err_type), Some(err_msg)) = (
1411 parsed.pointer("/error/type").and_then(|v| v.as_str()),
1412 parsed.pointer("/error/message").and_then(|v| v.as_str()),
1413 )
1414 {
1415 if status == 400 && err_msg.to_lowercase().contains("thinking") {
1419 return ModelError::Backend(BackendError::ProviderError {
1420 provider: "anthropic".to_string(),
1421 code: Some(err_type.to_string()),
1422 message: format!(
1423 "{} (thinking-block round-trip failed; this is a Mermaid bug — \
1424 please open an issue with the conversation that triggered it)",
1425 err_msg
1426 ),
1427 debug: debug.clone(),
1428 });
1429 }
1430 return ModelError::Backend(BackendError::ProviderError {
1431 provider: "anthropic".to_string(),
1432 code: Some(err_type.to_string()),
1433 message: err_msg.to_string(),
1434 debug: debug.clone(),
1435 });
1436 }
1437 ModelError::Backend(BackendError::HttpError {
1438 status,
1439 message: body,
1440 debug,
1441 })
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446 use super::*;
1447
1448 fn has_thinking_block(msgs: &[serde_json::Value]) -> bool {
1449 msgs.iter().any(|msg| {
1450 msg.get("content")
1451 .and_then(|c| c.as_array())
1452 .map(|blocks| {
1453 blocks
1454 .iter()
1455 .any(|b| b.get("type").and_then(|t| t.as_str()) == Some("thinking"))
1456 })
1457 .unwrap_or(false)
1458 })
1459 }
1460
1461 #[test]
1462 fn model_info_parses_documented_limit_fields() {
1463 let body = r#"{
1466 "id": "claude-sonnet-4-6",
1467 "type": "model",
1468 "display_name": "Claude Sonnet 4.6",
1469 "created_at": "2026-02-01T00:00:00Z",
1470 "max_input_tokens": 1000000,
1471 "max_tokens": 128000
1472 }"#;
1473 let info: AnthropicModelInfo = serde_json::from_str(body).expect("parse");
1474 let limits: ModelLimits = info.into();
1475 assert_eq!(limits.max_context_tokens, Some(1_000_000));
1476 assert_eq!(limits.max_output_tokens, Some(128_000));
1477 }
1478
1479 #[test]
1480 fn model_info_missing_limit_fields_degrade_to_none() {
1481 let body = r#"{"id": "claude-sonnet-4-6", "type": "model"}"#;
1484 let info: AnthropicModelInfo = serde_json::from_str(body).expect("parse");
1485 let limits: ModelLimits = info.into();
1486 assert_eq!(limits.max_context_tokens, None);
1487 assert_eq!(limits.max_output_tokens, None);
1488 }
1489
1490 #[test]
1491 fn maps_anthropic_stop_reasons() {
1492 assert_eq!(map_anthropic_stop_reason("end_turn"), FinishReason::Stop);
1493 assert_eq!(
1494 map_anthropic_stop_reason("max_tokens"),
1495 FinishReason::Length
1496 );
1497 assert_eq!(map_anthropic_stop_reason("tool_use"), FinishReason::ToolUse);
1498 assert_eq!(
1499 map_anthropic_stop_reason("refusal"),
1500 FinishReason::ContentFilter
1501 );
1502 }
1503
1504 #[test]
1505 fn stream_closed_abnormally_distinguishes_drop_from_completion() {
1506 assert!(stream_closed_abnormally(false, None));
1509 assert!(!stream_closed_abnormally(true, Some(&FinishReason::Stop)));
1511 assert!(!stream_closed_abnormally(false, Some(&FinishReason::Stop)));
1515 assert!(!stream_closed_abnormally(
1518 false,
1519 Some(&FinishReason::Length)
1520 ));
1521 assert!(!stream_closed_abnormally(true, None));
1523 }
1524
1525 #[test]
1526 fn finalize_block_recovers_tool_use() {
1527 let events: std::sync::Arc<std::sync::Mutex<Vec<StreamEvent>>> =
1530 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1531 let ev = events.clone();
1532 let cb: StreamCallback = std::sync::Arc::new(move |e| ev.lock().unwrap().push(e));
1533 let mut text = String::new();
1534 let mut thinking = String::new();
1535 let mut sig = None;
1536 let mut tools = Vec::new();
1537 finalize_block(
1538 BlockAccumulator::ToolUse {
1539 id: "tu_1".to_string(),
1540 name: "read_file".to_string(),
1541 input_buf: r#"{"path":"a.txt"}"#.to_string(),
1542 },
1543 &mut text,
1544 &mut thinking,
1545 &mut sig,
1546 &mut tools,
1547 &cb,
1548 );
1549 assert_eq!(tools.len(), 1);
1550 assert_eq!(tools[0].function.name, "read_file");
1551 assert_eq!(events.lock().unwrap().len(), 1);
1552 }
1553
1554 #[test]
1555 fn thinking_block_requires_signature() {
1556 let mut unsigned = ChatMessage::assistant("answer");
1559 unsigned.thinking = Some("private reasoning".to_string());
1560 let (_sys, msgs) = convert_messages(&[unsigned]);
1561 assert!(
1562 !has_thinking_block(&msgs),
1563 "unsigned thinking must be dropped"
1564 );
1565
1566 let mut signed = ChatMessage::assistant("answer").with_provider_continuation(
1567 ProviderContinuation::Anthropic {
1568 signature: "sig123".to_string(),
1569 },
1570 );
1571 signed.thinking = Some("private reasoning".to_string());
1572 let (_sys, msgs) = convert_messages(&[signed]);
1573 assert!(has_thinking_block(&msgs), "signed thinking must be present");
1574 }
1575
1576 fn test_adapter() -> AnthropicAdapter {
1577 AnthropicAdapter::new(
1578 "test-key".to_string(),
1579 "claude-sonnet-4-6".to_string(),
1580 "https://api.anthropic.com/v1".to_string(),
1581 )
1582 .expect("adapter constructs")
1583 }
1584
1585 #[test]
1588 fn thinking_format_dispatch() {
1589 assert_eq!(
1590 thinking_format_for("claude-opus-4-7"),
1591 ThinkingFormat::Adaptive
1592 );
1593 assert_eq!(
1594 thinking_format_for("claude-sonnet-4-6"),
1595 ThinkingFormat::Adaptive
1596 );
1597 assert_eq!(
1598 thinking_format_for("claude-opus-4-6"),
1599 ThinkingFormat::Adaptive
1600 );
1601 assert_eq!(
1603 thinking_format_for("claude-opus-4-8"),
1604 ThinkingFormat::Adaptive
1605 );
1606 assert_eq!(
1607 thinking_format_for("claude-fable-5"),
1608 ThinkingFormat::Adaptive
1609 );
1610 assert_eq!(
1611 thinking_format_for("claude-sonnet-4-5"),
1612 ThinkingFormat::Legacy
1613 );
1614 assert_eq!(
1615 thinking_format_for("claude-opus-4-5"),
1616 ThinkingFormat::Legacy
1617 );
1618 assert_eq!(
1619 thinking_format_for("claude-haiku-4-5"),
1620 ThinkingFormat::Legacy
1621 );
1622 assert_eq!(
1624 thinking_format_for("Claude-Opus-4-7-Special"),
1625 ThinkingFormat::Adaptive
1626 );
1627 assert_eq!(
1629 thinking_format_for("claude-future-99"),
1630 ThinkingFormat::Legacy
1631 );
1632 }
1633
1634 #[test]
1635 fn legacy_budget_clamps_to_max_tokens() {
1636 assert_eq!(legacy_budget_for(ReasoningLevel::High, 8000), Some(6976));
1639 assert_eq!(legacy_budget_for(ReasoningLevel::Low, 4096), Some(2048));
1641 assert_eq!(legacy_budget_for(ReasoningLevel::None, 4096), None);
1643 assert_eq!(legacy_budget_for(ReasoningLevel::Max, 64000), Some(32000));
1645 assert_eq!(legacy_budget_for(ReasoningLevel::Max, 2000), Some(1024));
1647 assert_eq!(legacy_budget_for(ReasoningLevel::High, 1024), None);
1650 assert_eq!(legacy_budget_for(ReasoningLevel::Max, 512), None);
1651 let b = legacy_budget_for(ReasoningLevel::High, 2048).expect("fits");
1653 assert!(b < 2048, "budget {b} must be < max_tokens");
1654 }
1655
1656 #[test]
1657 fn adaptive_effort_per_level() {
1658 let m = "claude-sonnet-4-6";
1659 assert_eq!(adaptive_effort_for(ReasoningLevel::None, m), None);
1660 assert_eq!(adaptive_effort_for(ReasoningLevel::Minimal, m), Some("low"));
1661 assert_eq!(adaptive_effort_for(ReasoningLevel::Low, m), Some("low"));
1662 assert_eq!(
1663 adaptive_effort_for(ReasoningLevel::Medium, m),
1664 Some("medium")
1665 );
1666 assert_eq!(adaptive_effort_for(ReasoningLevel::High, m), Some("high"));
1667 assert_eq!(adaptive_effort_for(ReasoningLevel::Max, m), Some("max"));
1669 }
1670
1671 #[test]
1676 fn adaptive_effort_uses_xhigh_on_opus_4_7_for_xhigh() {
1677 assert_eq!(
1678 adaptive_effort_for(ReasoningLevel::XHigh, "claude-opus-4-7"),
1679 Some("xhigh")
1680 );
1681 assert_eq!(
1684 adaptive_effort_for(ReasoningLevel::Max, "claude-opus-4-7"),
1685 Some("max")
1686 );
1687 assert_eq!(
1691 adaptive_effort_for(ReasoningLevel::XHigh, "claude-opus-4-6"),
1692 Some("high")
1693 );
1694 }
1695
1696 #[test]
1701 fn adaptive_effort_gates_max_on_4_5_family() {
1702 for m in ["claude-sonnet-4-5", "claude-haiku-4-5"] {
1703 assert_eq!(
1704 adaptive_effort_for(ReasoningLevel::Max, m),
1705 None,
1706 "model {} does not support the effort parameter at all",
1707 m
1708 );
1709 assert_eq!(
1710 adaptive_effort_for(ReasoningLevel::XHigh, m),
1711 None,
1712 "model {} does not support the effort parameter at all",
1713 m
1714 );
1715 }
1716 assert_eq!(
1718 adaptive_effort_for(ReasoningLevel::Max, "claude-opus-4-5"),
1719 Some("high"),
1720 "Opus 4.5 should snap Max → high (no max effort support)"
1721 );
1722 assert_eq!(
1723 adaptive_effort_for(ReasoningLevel::XHigh, "claude-opus-4-5"),
1724 Some("high"),
1725 "Opus 4.5 should snap XHigh → high"
1726 );
1727 }
1728
1729 #[test]
1732 fn tool_translation_drops_function_wrapper() {
1733 let openai_tool = json!({
1734 "type": "function",
1735 "function": {
1736 "name": "read_file",
1737 "description": "Read a file",
1738 "parameters": {
1739 "type": "object",
1740 "properties": {"path": {"type": "string"}},
1741 "required": ["path"]
1742 }
1743 }
1744 });
1745 let translated = to_anthropic_tools(&[&openai_tool]);
1746 assert_eq!(translated.len(), 1);
1747 assert_eq!(translated[0]["name"], "read_file");
1748 assert_eq!(translated[0]["description"], "Read a file");
1749 assert_eq!(translated[0]["type"], "custom");
1752 assert!(translated[0].get("function").is_none());
1755 assert_eq!(
1757 translated[0]["input_schema"]["properties"]["path"]["type"],
1758 "string"
1759 );
1760 }
1761
1762 #[test]
1763 fn tool_translation_handles_missing_description() {
1764 let openai_tool = json!({
1765 "type": "function",
1766 "function": {
1767 "name": "no_description_tool",
1768 "parameters": {"type": "object", "properties": {}}
1769 }
1770 });
1771 let translated = to_anthropic_tools(&[&openai_tool]);
1772 assert_eq!(translated[0]["description"], "");
1773 }
1774
1775 #[test]
1778 fn convert_messages_extracts_system_only_first() {
1779 let messages = vec![
1780 ChatMessage::system("You are helpful."),
1781 ChatMessage::user("Hello"),
1782 ChatMessage::system("This second system message is dropped."),
1783 ];
1784 let (system, msgs) = convert_messages(&messages);
1785 assert_eq!(system.as_deref(), Some("You are helpful."));
1786 assert_eq!(msgs.len(), 1);
1788 assert_eq!(msgs[0]["role"], "user");
1789 }
1790
1791 #[test]
1792 fn convert_messages_merges_consecutive_tool_messages() {
1793 let messages = vec![
1798 ChatMessage::user("Read three files"),
1799 {
1800 let mut m = ChatMessage::assistant("I will read them.");
1801 m.tool_calls = Some(vec![
1802 ToolCall {
1803 id: Some("c1".to_string()),
1804 function: FunctionCall {
1805 name: "read_file".into(),
1806 arguments: json!({"path": "a.txt"}),
1807 },
1808 },
1809 ToolCall {
1810 id: Some("c2".to_string()),
1811 function: FunctionCall {
1812 name: "read_file".into(),
1813 arguments: json!({"path": "b.txt"}),
1814 },
1815 },
1816 ToolCall {
1817 id: Some("c3".to_string()),
1818 function: FunctionCall {
1819 name: "read_file".into(),
1820 arguments: json!({"path": "c.txt"}),
1821 },
1822 },
1823 ]);
1824 m
1825 },
1826 ChatMessage::tool("c1", "read_file", "contents of a"),
1827 ChatMessage::tool("c2", "read_file", "contents of b"),
1828 ChatMessage::tool("c3", "read_file", "contents of c"),
1829 ChatMessage::assistant("Done."),
1830 ];
1831 let (_, msgs) = convert_messages(&messages);
1832 assert_eq!(msgs.len(), 4);
1835 assert_eq!(msgs[0]["role"], "user");
1836 assert_eq!(msgs[1]["role"], "assistant");
1837 assert_eq!(msgs[2]["role"], "user");
1838 assert_eq!(msgs[3]["role"], "assistant");
1839 let tool_results = msgs[2]["content"].as_array().expect("array");
1841 assert_eq!(tool_results.len(), 3);
1842 for (i, expected_id) in ["c1", "c2", "c3"].iter().enumerate() {
1843 assert_eq!(tool_results[i]["type"], "tool_result");
1844 assert_eq!(tool_results[i]["tool_use_id"], *expected_id);
1845 }
1846 }
1847
1848 #[test]
1849 fn convert_messages_emits_thinking_block_with_signature() {
1850 let mut msg = ChatMessage::assistant("Final answer.");
1851 msg.thinking = Some("reasoning content".to_string());
1852 msg.provider_continuation = Some(ProviderContinuation::Anthropic {
1853 signature: "sig_xyz".to_string(),
1854 });
1855 let messages = vec![ChatMessage::user("Q?"), msg];
1856 let (_, msgs) = convert_messages(&messages);
1857 let assistant_content = msgs[1]["content"].as_array().expect("array");
1858 assert_eq!(assistant_content[0]["type"], "thinking");
1860 assert_eq!(assistant_content[0]["thinking"], "reasoning content");
1861 assert_eq!(assistant_content[0]["signature"], "sig_xyz");
1862 assert_eq!(assistant_content[1]["type"], "text");
1863 assert_eq!(assistant_content[1]["text"], "Final answer.");
1864 }
1865
1866 #[test]
1867 fn convert_messages_image_block_for_user_with_images() {
1868 let msg = ChatMessage::user("What is this?").with_images(vec!["BASE64DATA".to_string()]);
1869 let messages = vec![msg];
1870 let (_, msgs) = convert_messages(&messages);
1871 let content = msgs[0]["content"].as_array().expect("array");
1872 assert_eq!(content[0]["type"], "text");
1873 assert_eq!(content[0]["text"], "What is this?");
1874 assert_eq!(content[1]["type"], "image");
1875 assert_eq!(content[1]["source"]["type"], "base64");
1876 assert_eq!(content[1]["source"]["media_type"], "image/png");
1877 assert_eq!(content[1]["source"]["data"], "BASE64DATA");
1878 }
1879
1880 #[test]
1883 fn build_request_body_includes_required_fields() {
1884 let adapter = test_adapter();
1885 let messages = vec![ChatMessage::user("Hello")];
1886 let config = ModelConfig::default();
1887 let body = adapter.build_request_body(&messages, &config);
1888 assert_eq!(body["model"], "claude-sonnet-4-6");
1889 assert_eq!(body["stream"], true);
1890 assert!(body["max_tokens"].is_u64());
1891 assert!(body["messages"].is_array());
1892 }
1893
1894 #[test]
1895 fn auto_max_tokens_uses_live_discovered_ceiling() {
1896 let adapter = test_adapter();
1900 let config = ModelConfig {
1901 max_tokens: 0,
1902 resolved_context_window: Some(1_000_000),
1903 resolved_max_output: Some(128_000),
1904 ..Default::default()
1905 };
1906 let body = adapter.build_request_body(&[ChatMessage::user("Hello")], &config);
1907 assert_eq!(body["max_tokens"], 128_000);
1908 }
1909
1910 #[test]
1911 fn auto_max_tokens_floors_when_discovery_unresolved() {
1912 let adapter = test_adapter();
1916 let config = ModelConfig {
1917 max_tokens: 0,
1918 ..Default::default()
1919 };
1920 let body = adapter.build_request_body(&[ChatMessage::user("Hello")], &config);
1921 assert_eq!(body["max_tokens"], 8_192);
1922 }
1923
1924 #[test]
1925 fn auto_max_tokens_clamps_to_window_room() {
1926 let adapter = test_adapter();
1930 let config = ModelConfig {
1931 max_tokens: 0,
1932 system_prompt: Some("sys".to_string()),
1933 resolved_context_window: Some(16_384),
1934 resolved_max_output: Some(128_000),
1935 ..Default::default()
1936 };
1937 let body = adapter.build_request_body(&[ChatMessage::user("Hello")], &config);
1938 assert_eq!(body["max_tokens"], 16_384 - 2 - 1_024);
1939 }
1940
1941 #[test]
1947 fn model_directed_system_messages_reach_the_wire_as_tagged_user_blocks() {
1948 use crate::models::ChatMessageKind;
1949 let mut nudge = ChatMessage::system("Reminder: plan mode is active.");
1950 nudge.kind = ChatMessageKind::RecoveryNudge;
1951 let messages = vec![ChatMessage::user("ok"), nudge];
1952
1953 let (_system, out) = convert_messages(&messages);
1954 assert_eq!(out.len(), 1, "merged into the adjacent user turn");
1955 assert_eq!(out[0]["role"], "user");
1956 let blocks = out[0]["content"].as_array().expect("content array");
1957 assert_eq!(blocks.len(), 2, "original text plus the reminder");
1958 assert_eq!(blocks[0]["text"], "ok");
1959 let tagged = blocks[1]["text"].as_str().unwrap();
1960 assert!(
1961 tagged.contains("<system-reminder>") && tagged.contains("plan mode is active"),
1962 "steering must be delivered and tagged: {tagged}",
1963 );
1964 }
1965
1966 #[test]
1970 fn model_directed_system_message_creates_a_user_turn_when_needed() {
1971 use crate::models::ChatMessageKind;
1972 let mut nudge = ChatMessage::system("Resume where you stopped.");
1973 nudge.kind = ChatMessageKind::ContextMarker;
1974 let messages = vec![ChatMessage::assistant("partial reply"), nudge];
1975
1976 let (_system, out) = convert_messages(&messages);
1977 assert_eq!(out.len(), 2);
1978 assert_eq!(out[0]["role"], "assistant");
1979 assert_eq!(out[1]["role"], "user", "alternation stays valid");
1980 assert!(
1981 out[1]["content"][0]["text"]
1982 .as_str()
1983 .unwrap()
1984 .contains("Resume where you stopped"),
1985 );
1986 }
1987
1988 #[test]
1997 fn convert_messages_never_emits_consecutive_same_role_turns() {
1998 use crate::models::ChatMessageKind;
1999 let steering = || {
2000 let mut m = ChatMessage::system("Reminder: plan mode is active.");
2001 m.kind = ChatMessageKind::ContextMarker;
2002 m
2003 };
2004 let tool_call = || {
2005 let mut m = ChatMessage::assistant("");
2006 m.tool_calls = Some(vec![ToolCall {
2007 id: Some("c1".to_string()),
2008 function: FunctionCall {
2009 name: "read_file".into(),
2010 arguments: json!({"path": "a.txt"}),
2011 },
2012 }]);
2013 m
2014 };
2015
2016 let shapes: Vec<(&str, Vec<ChatMessage>)> = vec![
2017 (
2018 "steering between two user turns",
2019 vec![
2020 ChatMessage::user("first"),
2021 steering(),
2022 ChatMessage::user("second"),
2023 ],
2024 ),
2025 (
2026 "two user turns in a row",
2027 vec![ChatMessage::user("first"), ChatMessage::user("second")],
2028 ),
2029 (
2030 "user types while tool results are pending",
2031 vec![
2032 ChatMessage::user("read it"),
2033 tool_call(),
2034 ChatMessage::tool("c1", "read_file", "contents"),
2035 ChatMessage::user("actually, stop"),
2036 ],
2037 ),
2038 (
2039 "two assistant turns from an interrupted continuation",
2040 vec![
2041 ChatMessage::user("go"),
2042 ChatMessage::assistant("part one"),
2043 ChatMessage::assistant("part two"),
2044 ],
2045 ),
2046 (
2047 "back-to-back steering",
2048 vec![ChatMessage::user("go"), steering(), steering()],
2049 ),
2050 (
2051 "steering with no user turn to attach to",
2052 vec![ChatMessage::assistant("partial"), steering()],
2053 ),
2054 ];
2055
2056 for (name, messages) in shapes {
2057 let (_system, out) = convert_messages(&messages);
2058 assert!(!out.is_empty(), "{name}: the history must not vanish");
2059 for pair in out.windows(2) {
2060 assert_ne!(
2061 pair[0]["role"], pair[1]["role"],
2062 "{name}: emitted consecutive {} turns, which Anthropic rejects: {out:#?}",
2063 pair[0]["role"],
2064 );
2065 }
2066 }
2067 }
2068
2069 #[test]
2073 fn coalescing_two_user_turns_keeps_both_texts() {
2074 let messages = vec![ChatMessage::user("first"), ChatMessage::user("second")];
2075 let (_system, out) = convert_messages(&messages);
2076 assert_eq!(out.len(), 1);
2077 let blocks = out[0]["content"].as_array().expect("content array");
2078 assert_eq!(blocks.len(), 2, "both texts survive: {blocks:#?}");
2079 assert_eq!(blocks[0]["text"], "first");
2080 assert_eq!(blocks[1]["text"], "second");
2081 }
2082
2083 #[test]
2087 fn merged_user_turn_keeps_tool_results_first() {
2088 let mut call = ChatMessage::assistant("");
2089 call.tool_calls = Some(vec![ToolCall {
2090 id: Some("c1".to_string()),
2091 function: FunctionCall {
2092 name: "read_file".into(),
2093 arguments: json!({"path": "a.txt"}),
2094 },
2095 }]);
2096 let messages = vec![
2097 ChatMessage::user("read it"),
2098 call,
2099 ChatMessage::tool("c1", "read_file", "contents"),
2100 ChatMessage::user("actually, stop"),
2101 ];
2102 let (_system, out) = convert_messages(&messages);
2103 let blocks = out[2]["content"].as_array().expect("content array");
2104 assert_eq!(out[2]["role"], "user");
2105 assert_eq!(blocks[0]["type"], "tool_result", "{blocks:#?}");
2106 assert_eq!(blocks[1]["type"], "text");
2107 assert_eq!(blocks[1]["text"], "actually, stop");
2108 }
2109
2110 #[test]
2113 fn merged_assistant_turn_keeps_thinking_first() {
2114 let mut second = ChatMessage::assistant("part two");
2115 second.thinking = Some("more reasoning".to_string());
2116 second.provider_continuation = Some(ProviderContinuation::Anthropic {
2117 signature: "sig_xyz".to_string(),
2118 });
2119 let messages = vec![
2120 ChatMessage::user("go"),
2121 ChatMessage::assistant("part one"),
2122 second,
2123 ];
2124 let (_system, out) = convert_messages(&messages);
2125 assert_eq!(out.len(), 2);
2126 let blocks = out[1]["content"].as_array().expect("content array");
2127 assert_eq!(blocks[0]["type"], "thinking", "{blocks:#?}");
2128 assert_eq!(blocks[1]["text"], "part one");
2129 assert_eq!(blocks[2]["text"], "part two");
2130 }
2131
2132 #[test]
2133 fn build_request_body_sets_system_field_not_message() {
2134 let adapter = test_adapter();
2135 let messages = vec![ChatMessage::user("Hi")];
2136 let config = ModelConfig {
2137 system_prompt: Some("You are Mermaid.".to_string()),
2138 ..Default::default()
2139 };
2140 let body = adapter.build_request_body(&messages, &config);
2141 let sys = body["system"].as_array().expect("system is array");
2144 assert_eq!(sys.len(), 1);
2145 assert_eq!(sys[0]["type"], "text");
2146 assert_eq!(sys[0]["text"], "You are Mermaid.");
2147 assert_eq!(sys[0]["cache_control"]["type"], "ephemeral");
2148 let msgs = body["messages"].as_array().unwrap();
2150 for m in msgs {
2151 assert_ne!(m["role"], "system");
2152 }
2153 }
2154
2155 #[test]
2161 fn build_request_body_emits_two_cache_blocks_when_suffix_present() {
2162 let adapter = test_adapter();
2163 let messages = vec![ChatMessage::user("Hi")];
2164 let config = ModelConfig {
2165 system_prompt: Some("You are Mermaid.".to_string()),
2166 dynamic_system_suffix: Some("Project rule: always snake_case.".to_string()),
2167 ..Default::default()
2168 };
2169 let body = adapter.build_request_body(&messages, &config);
2170 let sys = body["system"].as_array().expect("system is array");
2171 assert_eq!(sys.len(), 2);
2172 assert_eq!(sys[0]["text"], "You are Mermaid.");
2173 assert_eq!(sys[0]["cache_control"]["type"], "ephemeral");
2174 assert_eq!(sys[1]["text"], "Project rule: always snake_case.");
2175 assert_eq!(sys[1]["cache_control"]["type"], "ephemeral");
2176 }
2177
2178 #[test]
2182 fn build_request_body_emits_single_block_when_suffix_absent() {
2183 let adapter = test_adapter();
2184 let messages = vec![ChatMessage::user("Hi")];
2185 let config = ModelConfig {
2186 system_prompt: Some("You are Mermaid.".to_string()),
2187 dynamic_system_suffix: None,
2188 ..Default::default()
2189 };
2190 let body = adapter.build_request_body(&messages, &config);
2191 let sys = body["system"].as_array().expect("system is array");
2192 assert_eq!(sys.len(), 1);
2193 assert_eq!(sys[0]["text"], "You are Mermaid.");
2194 }
2195
2196 #[test]
2199 fn build_request_body_maps_output_schema_to_output_config_format() {
2200 let adapter = test_adapter();
2201 let messages = vec![ChatMessage::user("format it")];
2202 let config = ModelConfig {
2203 reasoning: ReasoningLevel::High,
2204 output_schema: Some(serde_json::json!({
2205 "type": "object",
2206 "properties": {"answer": {"type": "integer"}}
2207 })),
2208 ..Default::default()
2209 };
2210 let body = adapter.build_request_body(&messages, &config);
2211 assert_eq!(body["output_config"]["format"]["type"], "json_schema");
2212 assert_eq!(body["output_config"]["format"]["schema"]["type"], "object");
2213 assert_eq!(body["output_config"]["effort"], "high");
2215 let body = adapter.build_request_body(&messages, &ModelConfig::default());
2217 assert!(body["output_config"].get("format").is_none());
2218 }
2219
2220 #[test]
2224 fn build_request_body_uses_adaptive_for_sonnet_4_6() {
2225 let adapter = test_adapter(); let messages = vec![ChatMessage::user("Hi")];
2227 let config = ModelConfig {
2228 reasoning: ReasoningLevel::High,
2229 ..Default::default()
2230 };
2231 let body = adapter.build_request_body(&messages, &config);
2232 assert_eq!(body["thinking"]["type"], "adaptive");
2233 assert_eq!(body["thinking"]["display"], "summarized");
2234 assert_eq!(body["output_config"]["effort"], "high");
2236 assert!(body.get("effort").is_none(), "effort must NOT be top-level");
2237 assert!(body["thinking"].get("budget_tokens").is_none());
2238 }
2239
2240 #[test]
2245 fn build_request_body_uses_legacy_for_sonnet_4_5() {
2246 let adapter = AnthropicAdapter::new(
2247 "k".to_string(),
2248 "claude-sonnet-4-5".to_string(),
2249 "https://api.anthropic.com/v1".to_string(),
2250 )
2251 .unwrap();
2252 let messages = vec![ChatMessage::user("Hi")];
2253 let config = ModelConfig {
2254 reasoning: ReasoningLevel::Medium,
2255 max_tokens: 8000,
2256 ..Default::default()
2257 };
2258 let body = adapter.build_request_body(&messages, &config);
2259 assert_eq!(body["thinking"]["type"], "enabled");
2260 assert_eq!(body["thinking"]["budget_tokens"], 4096);
2261 assert!(
2263 body.get("output_config").is_none(),
2264 "Sonnet 4.5 must not get an effort field"
2265 );
2266 assert!(body.get("temperature").is_some());
2268 }
2269
2270 #[test]
2273 fn build_request_body_adaptive_no_temperature_for_opus_4_8() {
2274 let adapter = AnthropicAdapter::new(
2275 "k".to_string(),
2276 "claude-opus-4-8".to_string(),
2277 "https://api.anthropic.com/v1".to_string(),
2278 )
2279 .unwrap();
2280 let messages = vec![ChatMessage::user("Hi")];
2281 let config = ModelConfig {
2282 reasoning: ReasoningLevel::High,
2283 ..Default::default()
2284 };
2285 let body = adapter.build_request_body(&messages, &config);
2286 assert_eq!(body["thinking"]["type"], "adaptive");
2287 assert!(
2288 body["thinking"].get("budget_tokens").is_none(),
2289 "Opus 4.8 rejects legacy budget_tokens"
2290 );
2291 assert_eq!(body["output_config"]["effort"], "high");
2292 assert!(
2293 body.get("temperature").is_none(),
2294 "Opus 4.8 rejects a top-level temperature"
2295 );
2296 }
2297
2298 #[test]
2299 fn build_request_body_omits_thinking_when_reasoning_is_none() {
2300 let adapter = test_adapter();
2301 let messages = vec![ChatMessage::user("Hi")];
2302 let config = ModelConfig {
2303 reasoning: ReasoningLevel::None,
2304 ..Default::default()
2305 };
2306 let body = adapter.build_request_body(&messages, &config);
2307 assert!(body.get("thinking").is_none());
2308 assert!(body.get("output_config").is_none());
2312 assert!(body.get("effort").is_none(), "no top-level effort either");
2313 }
2314
2315 #[test]
2319 fn build_request_body_uses_xhigh_on_opus_4_7_for_xhigh() {
2320 let adapter = AnthropicAdapter::new(
2321 "k".to_string(),
2322 "claude-opus-4-7".to_string(),
2323 "https://api.anthropic.com/v1".to_string(),
2324 )
2325 .unwrap();
2326 let messages = vec![ChatMessage::user("Hi")];
2327 let config = ModelConfig {
2328 reasoning: ReasoningLevel::XHigh,
2329 ..Default::default()
2330 };
2331 let body = adapter.build_request_body(&messages, &config);
2332 assert_eq!(body["output_config"]["effort"], "xhigh");
2333 assert_eq!(body["thinking"]["type"], "adaptive");
2334 }
2335
2336 #[test]
2339 fn build_request_body_uses_max_on_opus_4_6_for_max() {
2340 let adapter = AnthropicAdapter::new(
2341 "k".to_string(),
2342 "claude-opus-4-6".to_string(),
2343 "https://api.anthropic.com/v1".to_string(),
2344 )
2345 .unwrap();
2346 let messages = vec![ChatMessage::user("Hi")];
2347 let config = ModelConfig {
2348 reasoning: ReasoningLevel::Max,
2349 ..Default::default()
2350 };
2351 let body = adapter.build_request_body(&messages, &config);
2352 assert_eq!(body["output_config"]["effort"], "max");
2353 }
2354
2355 #[test]
2359 fn build_request_body_snaps_max_to_high_on_opus_4_5() {
2360 let adapter = AnthropicAdapter::new(
2361 "k".to_string(),
2362 "claude-opus-4-5".to_string(),
2363 "https://api.anthropic.com/v1".to_string(),
2364 )
2365 .unwrap();
2366 let messages = vec![ChatMessage::user("Hi")];
2367 let config = ModelConfig {
2368 reasoning: ReasoningLevel::Max,
2369 max_tokens: 8000,
2370 ..Default::default()
2371 };
2372 let body = adapter.build_request_body(&messages, &config);
2373 assert_eq!(
2374 body["output_config"]["effort"], "high",
2375 "Opus 4.5 should snap Max → high (no max effort support)"
2376 );
2377 }
2378
2379 #[test]
2384 fn build_request_body_sets_display_summarized_by_default() {
2385 let adapter = test_adapter(); let messages = vec![ChatMessage::user("Hi")];
2387 let config = ModelConfig {
2388 reasoning: ReasoningLevel::Medium,
2389 hide_reasoning_trace: false,
2390 ..Default::default()
2391 };
2392 let body = adapter.build_request_body(&messages, &config);
2393 assert_eq!(body["thinking"]["display"], "summarized");
2394 }
2395
2396 #[test]
2400 fn build_request_body_sets_display_omitted_when_hide_reasoning_trace() {
2401 let adapter = test_adapter();
2402 let messages = vec![ChatMessage::user("Hi")];
2403 let config = ModelConfig {
2404 reasoning: ReasoningLevel::Medium,
2405 hide_reasoning_trace: true,
2406 ..Default::default()
2407 };
2408 let body = adapter.build_request_body(&messages, &config);
2409 assert_eq!(body["thinking"]["display"], "omitted");
2410 }
2411
2412 #[test]
2413 fn build_request_body_clamps_temperature_to_anthropic_range() {
2414 let adapter = test_adapter();
2415 let messages = vec![ChatMessage::user("Hi")];
2416 let config = ModelConfig {
2417 temperature: 1.5, ..Default::default()
2419 };
2420 let body = adapter.build_request_body(&messages, &config);
2421 assert_eq!(body["temperature"].as_f64().unwrap(), 1.0);
2422 }
2423
2424 #[test]
2425 fn build_request_body_includes_tools_in_anthropic_shape() {
2426 let adapter = test_adapter();
2427 let messages = vec![ChatMessage::user("Hi")];
2428 let config = ModelConfig {
2432 tools: vec![serde_json::json!({
2433 "type": "function",
2434 "function": {
2435 "name": "test_tool",
2436 "description": "a test tool",
2437 "parameters": {"type": "object", "properties": {}}
2438 }
2439 })],
2440 ..Default::default()
2441 };
2442 let body = adapter.build_request_body(&messages, &config);
2443 let tools = body["tools"].as_array().expect("tools array");
2444 assert!(!tools.is_empty());
2445 for tool in tools {
2446 assert_eq!(tool["type"], "custom");
2447 assert!(tool.get("function").is_none());
2448 assert!(tool.get("name").is_some());
2449 assert!(tool.get("input_schema").is_some());
2450 }
2451 }
2452
2453 #[test]
2454 fn build_request_body_preserves_registry_selected_web_tools() {
2455 let adapter = test_adapter();
2456 let config = ModelConfig {
2457 tools: ["web_fetch", "web_search"]
2458 .into_iter()
2459 .map(|name| {
2460 serde_json::json!({
2461 "type": "function",
2462 "function": {
2463 "name": name,
2464 "description": "registered web tool",
2465 "parameters": {"type": "object"}
2466 }
2467 })
2468 })
2469 .collect(),
2470 ..Default::default()
2471 };
2472
2473 let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config);
2474 let names: Vec<&str> = body["tools"]
2475 .as_array()
2476 .expect("tools array")
2477 .iter()
2478 .filter_map(|tool| tool.get("name").and_then(Value::as_str))
2479 .collect();
2480 assert_eq!(names, ["web_fetch", "web_search"]);
2481 }
2482
2483 #[test]
2488 fn build_request_body_marks_only_last_tool_with_cache_control() {
2489 let adapter = test_adapter();
2490 let messages = vec![ChatMessage::user("Hi")];
2491 let config = ModelConfig {
2492 tools: vec![
2493 serde_json::json!({
2494 "type": "function",
2495 "function": {
2496 "name": "tool_a",
2497 "description": "first",
2498 "parameters": {"type": "object"}
2499 }
2500 }),
2501 serde_json::json!({
2502 "type": "function",
2503 "function": {
2504 "name": "tool_b",
2505 "description": "second",
2506 "parameters": {"type": "object"}
2507 }
2508 }),
2509 serde_json::json!({
2510 "type": "function",
2511 "function": {
2512 "name": "tool_c",
2513 "description": "third",
2514 "parameters": {"type": "object"}
2515 }
2516 }),
2517 ],
2518 ..Default::default()
2519 };
2520 let body = adapter.build_request_body(&messages, &config);
2521 let tools = body["tools"].as_array().expect("tools array");
2522 assert!(
2523 tools.len() >= 2,
2524 "need at least 2 tools to verify marker placement"
2525 );
2526
2527 for tool in &tools[..tools.len() - 1] {
2529 assert!(
2530 tool.get("cache_control").is_none(),
2531 "non-last tool should not carry cache_control: {:?}",
2532 tool
2533 );
2534 }
2535 let last = &tools[tools.len() - 1];
2537 assert_eq!(
2538 last["cache_control"]["type"], "ephemeral",
2539 "last tool should carry the cache_control marker"
2540 );
2541 }
2542
2543 #[test]
2548 fn build_request_body_handles_empty_tools_without_panicking() {
2549 let result = to_anthropic_tools(&[]);
2554 assert!(result.is_empty(), "empty input must produce empty output");
2555 }
2556
2557 #[test]
2558 fn capabilities_advertise_full_reasoning_levels_and_vision() {
2559 let adapter = test_adapter();
2560 let caps = adapter.capabilities();
2561 assert!(caps.supports_tools);
2562 assert!(caps.supports_vision);
2563 match &caps.supports_reasoning {
2564 ReasoningCapability::Levels(levels) => {
2565 assert!(levels.contains(&ReasoningLevel::None));
2566 assert!(levels.contains(&ReasoningLevel::Max));
2567 },
2568 other => panic!("expected Levels, got {:?}", other),
2569 }
2570 }
2571
2572 #[test]
2573 fn name_returns_model_id() {
2574 let adapter = test_adapter();
2575 assert_eq!(adapter.name(), "claude-sonnet-4-6");
2576 }
2577}