1pub(crate) mod data;
8
9use crate::attachments::validate_request_attachments;
10use crate::provider::{LlmProvider, thinking_for_forced_tool};
11use crate::streaming::{StreamBox, StreamDelta, StreamErrorKind};
12use agent_sdk_foundation::llm::{
13 CacheTtl, ChatOutcome, ChatRequest, ChatResponse, ContentBlock, ThinkingConfig, ThinkingMode,
14 Usage,
15};
16use anyhow::Result;
17use async_trait::async_trait;
18use data::{
19 ApiMessagesRequest, ApiOutputConfig, ApiThinkingConfig, ApiToolChoice, build_api_messages,
20 build_api_tools_with_cache, is_message_stop_event, map_content_blocks, map_stop_reason,
21 parse_sse_event, take_next_sse_event,
22};
23use futures::StreamExt;
24use reqwest::StatusCode;
25
26const API_BASE_URL: &str = "https://api.anthropic.com";
27const API_VERSION: &str = "2023-06-01";
28const CLAUDE_CODE_VERSION: &str = "2.1.75";
29const DEFAULT_SAFE_MAX_OUTPUT_TOKENS: u32 = 32_000;
30const MODELS_PAGE_LIMIT: u32 = 1000;
32const MODELS_MAX_PAGES: usize = 100;
36
37pub const MODEL_HAIKU_35: &str = "claude-3-5-haiku-20241022";
38pub const MODEL_SONNET_35: &str = "claude-3-5-sonnet-20241022";
39pub const MODEL_SONNET_4: &str = "claude-sonnet-4-20250514";
40pub const MODEL_OPUS_4: &str = "claude-opus-4-20250514";
41
42pub const MODEL_HAIKU_45: &str = "claude-haiku-4-5-20251001";
43pub const MODEL_SONNET_45: &str = "claude-sonnet-4-5-20250929";
44pub const MODEL_SONNET_46: &str = "claude-sonnet-4-6";
45pub const MODEL_SONNET_5: &str = "claude-sonnet-5";
46pub const MODEL_OPUS_46: &str = "claude-opus-4-6";
47pub const MODEL_OPUS_47: &str = "claude-opus-4-7";
48pub const MODEL_OPUS_48: &str = "claude-opus-4-8";
49pub const MODEL_FABLE_5: &str = "claude-fable-5";
50
51const CLAUDE_CODE_TOOLS: &[&str] = &[
58 "Read",
59 "Write",
60 "Edit",
61 "Bash",
62 "Grep",
63 "Glob",
64 "AskUserQuestion",
65 "EnterPlanMode",
66 "ExitPlanMode",
67 "KillShell",
68 "NotebookEdit",
69 "Skill",
70 "Task",
71 "TaskOutput",
72 "TodoWrite",
73 "WebFetch",
74 "WebSearch",
75];
76
77fn to_claude_code_name(name: &str) -> String {
79 let lower = name.to_lowercase();
80 for cc_name in CLAUDE_CODE_TOOLS {
81 if cc_name.to_lowercase() == lower {
82 return (*cc_name).to_string();
83 }
84 }
85 name.to_string()
86}
87
88fn from_claude_code_name(name: &str, original_names: &[String]) -> String {
90 let lower = name.to_lowercase();
91 for original in original_names {
92 if original.to_lowercase() == lower {
93 return original.clone();
94 }
95 }
96 name.to_string()
97}
98
99fn oauth_tool_name_collision(
107 tools: Option<&[agent_sdk_foundation::llm::Tool]>,
108) -> Option<(String, String)> {
109 let tools = tools?;
110 for (index, tool) in tools.iter().enumerate() {
111 for other in &tools[index + 1..] {
112 if tool.name != other.name && tool.name.eq_ignore_ascii_case(&other.name) {
113 return Some((tool.name.clone(), other.name.clone()));
114 }
115 }
116 }
117 None
118}
119
120fn oauth_tool_collision_message(first: &str, second: &str) -> String {
121 format!(
122 "OAuth tool names collide case-insensitively: '{first}' and '{second}' would map to the same Claude Code tool name; rename one to disambiguate"
123 )
124}
125
126#[must_use]
128pub fn is_oauth_token(api_key: &str) -> bool {
129 api_key.starts_with("sk-ant-oat")
130}
131
132struct AnthropicModelsPage {
135 models: Vec<crate::provider::ModelInfo>,
136 has_more: bool,
137 last_id: Option<String>,
138}
139
140fn parse_models_page(body: &str) -> Result<AnthropicModelsPage> {
147 #[derive(serde::Deserialize)]
148 struct ListResponse {
149 #[serde(default)]
150 data: Vec<ModelRow>,
151 #[serde(default)]
152 has_more: bool,
153 #[serde(default)]
154 last_id: Option<String>,
155 }
156 #[derive(serde::Deserialize)]
157 struct ModelRow {
158 id: String,
159 #[serde(default)]
160 display_name: Option<String>,
161 }
162 let parsed: ListResponse = serde_json::from_str(body)
163 .map_err(|e| anyhow::anyhow!("failed to parse Anthropic models list: {e}"))?;
164 let models = parsed
165 .data
166 .into_iter()
167 .map(|row| crate::provider::ModelInfo {
168 id: row.id,
169 display_name: row.display_name,
170 context_window: None,
171 max_output_tokens: None,
172 })
173 .collect();
174 Ok(AnthropicModelsPage {
175 models,
176 has_more: parsed.has_more,
177 last_id: parsed.last_id,
178 })
179}
180
181struct CacheRegions {
185 tools: Option<data::ApiCacheControl>,
186 system: Option<data::ApiCacheControl>,
187 messages: Option<data::ApiCacheControl>,
188}
189
190impl CacheRegions {
191 const DISABLED: Self = Self {
193 tools: None,
194 system: None,
195 messages: None,
196 };
197}
198
199#[derive(Clone, Debug)]
201enum AuthMode {
202 ApiKey,
204 OAuth,
206}
207
208#[derive(Clone)]
210pub struct AnthropicProvider {
211 client: reqwest::Client,
212 api_key: String,
213 model: String,
214 base_url: String,
215 auth_mode: AuthMode,
216 thinking: Option<ThinkingConfig>,
217 extra_headers: Vec<(String, String)>,
219}
220
221impl AnthropicProvider {
222 pub const API_KEY_ENV: &'static str = "ANTHROPIC_API_KEY";
224
225 #[must_use]
230 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
231 let api_key = api_key.into();
232 let model = model.into();
233 let auth_mode = if is_oauth_token(&api_key) {
234 AuthMode::OAuth
235 } else {
236 AuthMode::ApiKey
237 };
238
239 let client = reqwest::Client::builder()
244 .connect_timeout(std::time::Duration::from_secs(30))
245 .tcp_keepalive(std::time::Duration::from_secs(30))
246 .build()
247 .unwrap_or_default();
248
249 Self {
250 client,
251 api_key,
252 model,
253 base_url: API_BASE_URL.to_owned(),
254 auth_mode,
255 thinking: None,
256 extra_headers: Vec::new(),
257 }
258 }
259
260 #[must_use]
262 pub const fn is_oauth(&self) -> bool {
263 matches!(self.auth_mode, AuthMode::OAuth)
264 }
265
266 fn apply_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
272 let builder = if self.api_key.is_empty() {
273 builder.header("anthropic-version", API_VERSION)
274 } else {
275 match self.auth_mode {
276 AuthMode::ApiKey => {
277 let builder = builder
278 .header("x-api-key", &self.api_key)
279 .header("anthropic-version", API_VERSION);
280 if self.requires_adaptive_thinking() {
288 builder
289 } else {
290 builder.header("anthropic-beta", "interleaved-thinking-2025-05-14")
291 }
292 }
293 AuthMode::OAuth => {
294 let mut beta_features = vec![
298 "claude-code-20250219",
299 "oauth-2025-04-20",
300 "fine-grained-tool-streaming-2025-05-14",
301 ];
302 if !self.requires_adaptive_thinking() {
303 beta_features.push("interleaved-thinking-2025-05-14");
304 }
305 builder
306 .header("Authorization", format!("Bearer {}", self.api_key))
307 .header("anthropic-version", API_VERSION)
308 .header("anthropic-beta", beta_features.join(","))
309 .header("user-agent", format!("claude-cli/{CLAUDE_CODE_VERSION}"))
310 .header("x-app", "cli")
311 }
312 }
313 };
314 self.extra_headers
315 .iter()
316 .fold(builder, |b, (k, v)| b.header(k.as_str(), v.as_str()))
317 }
318
319 const OAUTH_IDENTITY: &'static str =
320 "You are Claude Code, Anthropic's official CLI for Claude.";
321
322 fn build_system_prompt_for_request<'a>(
328 &self,
329 system: &'a str,
330 cache_control: Option<data::ApiCacheControl>,
331 ) -> Option<data::ApiSystemPrompt<'a>> {
332 match self.auth_mode {
333 AuthMode::ApiKey => data::build_api_system_prompt(system, cache_control),
334 AuthMode::OAuth => {
335 let mut blocks = vec![data::ApiSystemBlock {
336 block_type: "text",
337 text: Self::OAUTH_IDENTITY,
338 cache_control: cache_control.clone(),
339 }];
340 if !system.is_empty() {
341 blocks.push(data::ApiSystemBlock {
342 block_type: "text",
343 text: system,
344 cache_control,
345 });
346 }
347 Some(data::ApiSystemPrompt::Blocks(blocks))
348 }
349 }
350 }
351
352 fn cache_regions(request: &ChatRequest) -> CacheRegions {
361 let (enabled, ttl, max_breakpoints) =
362 request.cache.as_ref().map_or((true, None, None), |cfg| {
363 (cfg.enabled, cfg.ttl, cfg.max_breakpoints)
364 });
365 if !enabled {
366 return CacheRegions::DISABLED;
367 }
368 let control = data::ApiCacheControl::ephemeral_with_ttl(ttl.map(CacheTtl::as_wire_str));
369 let limit = max_breakpoints.unwrap_or(u8::MAX);
370 CacheRegions {
371 tools: (limit >= 1).then(|| control.clone()),
372 system: (limit >= 2).then(|| control.clone()),
373 messages: (limit >= 3).then_some(control),
374 }
375 }
376
377 fn build_cached_api_messages(
378 request: &ChatRequest,
379 cache_control: Option<data::ApiCacheControl>,
380 ) -> Vec<data::ApiMessage> {
381 let mut messages = build_api_messages(request);
382 if let Some(cache_control) = cache_control {
383 data::apply_cache_control_to_last_user_message(&mut messages, cache_control);
384 }
385 messages
386 }
387
388 fn effective_max_tokens(&self, request: &ChatRequest) -> u32 {
389 if request.max_tokens_explicit {
390 request.max_tokens
391 } else {
392 self.default_max_tokens()
393 }
394 }
395
396 #[must_use]
409 pub fn from_env() -> Self {
410 Self::try_from_env().unwrap_or_else(|e| panic!("{e}"))
411 }
412
413 pub fn try_from_env() -> Result<Self> {
421 let api_key = std::env::var(Self::API_KEY_ENV).map_err(|_| {
422 anyhow::anyhow!("environment variable `{}` is not set", Self::API_KEY_ENV)
423 })?;
424 Ok(Self::sonnet(api_key))
425 }
426
427 #[must_use]
429 pub fn haiku(api_key: impl Into<String>) -> Self {
430 Self::new(api_key, MODEL_HAIKU_45)
431 }
432
433 #[must_use]
435 pub fn sonnet(api_key: impl Into<String>) -> Self {
436 Self::new(api_key, MODEL_SONNET_46)
437 }
438
439 #[must_use]
441 pub fn sonnet_45(api_key: impl Into<String>) -> Self {
442 Self::new(api_key, MODEL_SONNET_45)
443 }
444
445 #[must_use]
447 pub fn sonnet_46(api_key: impl Into<String>) -> Self {
448 Self::new(api_key, MODEL_SONNET_46)
449 }
450
451 #[must_use]
453 pub fn opus(api_key: impl Into<String>) -> Self {
454 Self::new(api_key, MODEL_OPUS_46)
455 }
456
457 #[must_use]
464 pub fn opus_47(api_key: impl Into<String>) -> Self {
465 Self::new(api_key, MODEL_OPUS_47)
466 }
467
468 #[must_use]
475 pub fn opus_48(api_key: impl Into<String>) -> Self {
476 Self::new(api_key, MODEL_OPUS_48)
477 }
478
479 #[must_use]
488 pub fn fable(api_key: impl Into<String>) -> Self {
489 Self::new(api_key, MODEL_FABLE_5)
490 }
491
492 #[must_use]
495 pub fn sonnet_5(api_key: impl Into<String>) -> Self {
496 Self::new(api_key, MODEL_SONNET_5)
497 }
498
499 #[must_use]
501 pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
502 self.thinking = Some(thinking);
503 self
504 }
505
506 #[must_use]
508 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
509 self.base_url = base_url.into();
510 self
511 }
512
513 #[must_use]
515 pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
516 self.extra_headers = headers;
517 self
518 }
519
520 fn requires_adaptive_thinking(&self) -> bool {
521 matches!(
522 self.model.as_str(),
523 MODEL_SONNET_46
524 | MODEL_SONNET_5
525 | MODEL_OPUS_46
526 | MODEL_OPUS_47
527 | MODEL_OPUS_48
528 | MODEL_FABLE_5
529 )
530 }
531}
532
533#[async_trait]
534#[allow(clippy::too_many_lines)]
535impl LlmProvider for AnthropicProvider {
536 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
537 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
538 Ok(thinking) => thinking_for_forced_tool(thinking, request.tool_choice.as_ref()),
542 Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
543 };
544 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
545 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
546 }
547 if self.is_oauth()
548 && let Some((first, second)) = oauth_tool_name_collision(request.tools.as_deref())
549 {
550 return Ok(ChatOutcome::InvalidRequest(oauth_tool_collision_message(
551 &first, &second,
552 )));
553 }
554 let CacheRegions {
555 tools: tools_cache,
556 system: system_cache,
557 messages: messages_cache,
558 } = Self::cache_regions(&request);
559 let messages = Self::build_cached_api_messages(&request, messages_cache);
560 let tools = if self.is_oauth() {
561 build_api_tools_with_cache(&request, tools_cache).map(|tools| {
562 tools
563 .into_iter()
564 .map(|mut t| {
565 t.name = to_claude_code_name(&t.name);
566 t
567 })
568 .collect::<Vec<_>>()
569 })
570 } else {
571 build_api_tools_with_cache(&request, tools_cache)
572 };
573 let thinking = thinking_config
574 .as_ref()
575 .map(ApiThinkingConfig::from_thinking_config);
576 let output_config = thinking_config
577 .as_ref()
578 .and_then(|t| t.effort)
579 .map(|effort| ApiOutputConfig { effort });
580
581 let system = self.build_system_prompt_for_request(&request.system, system_cache);
582 let max_tokens = self.effective_max_tokens(&request);
583 let tool_choice = request
584 .tool_choice
585 .as_ref()
586 .map(ApiToolChoice::from_tool_choice);
587
588 let api_request = ApiMessagesRequest {
589 model: Some(&self.model),
590 max_tokens,
591 system,
592 messages: &messages,
593 tools: tools.as_deref(),
594 tool_choice,
595 stream: false,
596 thinking,
597 output_config,
598 anthropic_version: None,
599 };
600
601 log::debug!(
602 "Anthropic LLM request model={} max_tokens={} oauth={}",
603 self.model,
604 max_tokens,
605 self.is_oauth()
606 );
607
608 if log::log_enabled!(log::Level::Debug) {
610 match serde_json::to_string_pretty(&api_request) {
611 Ok(json) => log::debug!("Anthropic API request payload:\n{json}"),
612 Err(e) => log::debug!("Failed to serialize request for logging: {e}"),
613 }
614 }
615
616 let builder = self
617 .client
618 .post(format!("{}/v1/messages", self.base_url))
619 .header("Content-Type", "application/json");
620 let response = self
621 .apply_auth(builder)
622 .json(&api_request)
623 .send()
624 .await
625 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
626
627 let status = response.status();
628 let retry_after = if status == StatusCode::TOO_MANY_REQUESTS {
631 crate::http::retry_after_from_headers(response.headers())
632 } else {
633 None
634 };
635 let bytes = response
636 .bytes()
637 .await
638 .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
639
640 log::debug!(
641 "Anthropic LLM response status={} body_len={}",
642 status,
643 bytes.len()
644 );
645
646 if status == StatusCode::TOO_MANY_REQUESTS {
647 return Ok(ChatOutcome::RateLimited(retry_after));
648 }
649
650 if status.is_server_error() {
651 let body = String::from_utf8_lossy(&bytes);
652 log::error!("Anthropic server error status={status} body={body}");
653 return Ok(ChatOutcome::ServerError(body.into_owned()));
654 }
655
656 if status.is_client_error() {
657 let body = String::from_utf8_lossy(&bytes);
658 log::warn!("Anthropic client error status={status} body={body}");
659 return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
660 }
661
662 let api_response: data::ApiResponse = serde_json::from_slice(&bytes)
663 .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
664
665 log::debug!(
667 "Anthropic API response: id={} model={} stop_reason={:?} usage={{input_tokens={}, output_tokens={}}} content_blocks={}",
668 api_response.id,
669 api_response.model,
670 api_response.stop_reason,
671 api_response.usage.total_input_tokens(),
672 api_response.usage.output,
673 api_response.content.len()
674 );
675
676 let mut content = map_content_blocks(api_response.content);
677
678 if self.is_oauth() {
680 let original_names: Vec<String> = request
681 .tools
682 .as_ref()
683 .map(|ts| ts.iter().map(|t| t.name.clone()).collect())
684 .unwrap_or_default();
685 for block in &mut content {
686 if let ContentBlock::ToolUse { name, .. } = block {
687 *name = from_claude_code_name(name, &original_names);
688 }
689 }
690 }
691
692 let stop_reason = api_response.stop_reason.as_ref().map(map_stop_reason);
693
694 Ok(ChatOutcome::Success(ChatResponse {
695 id: api_response.id,
696 content,
697 model: api_response.model,
698 stop_reason,
699 usage: Usage {
700 input_tokens: api_response.usage.total_input_tokens(),
701 output_tokens: api_response.usage.output,
702 cached_input_tokens: api_response.usage.cached_input_tokens(),
703 cache_creation_input_tokens: api_response.usage.cache_creation_input_tokens(),
704 },
705 }))
706 }
707
708 fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
709 Box::pin(async_stream::stream! {
710 let is_oauth = self.is_oauth();
711 let original_tool_names: Vec<String> = request
712 .tools
713 .as_ref()
714 .map(|ts| ts.iter().map(|t| t.name.clone()).collect())
715 .unwrap_or_default();
716
717 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
718 yield Ok(StreamDelta::Error {
719 message: error.to_string(),
720 kind: StreamErrorKind::InvalidRequest,
721 });
722 return;
723 }
724
725 if is_oauth
726 && let Some((first, second)) = oauth_tool_name_collision(request.tools.as_deref())
727 {
728 yield Ok(StreamDelta::Error {
729 message: oauth_tool_collision_message(&first, &second),
730 kind: StreamErrorKind::InvalidRequest,
731 });
732 return;
733 }
734
735 let CacheRegions {
736 tools: tools_cache,
737 system: system_cache,
738 messages: messages_cache,
739 } = Self::cache_regions(&request);
740 let messages = Self::build_cached_api_messages(&request, messages_cache);
741 let tools = if is_oauth {
742 build_api_tools_with_cache(&request, tools_cache).map(|tools| {
743 tools
744 .into_iter()
745 .map(|mut t| {
746 t.name = to_claude_code_name(&t.name);
747 t
748 })
749 .collect::<Vec<_>>()
750 })
751 } else {
752 build_api_tools_with_cache(&request, tools_cache)
753 };
754 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
755 Ok(thinking) => thinking_for_forced_tool(thinking, request.tool_choice.as_ref()),
760 Err(error) => {
761 yield Ok(StreamDelta::Error {
762 message: error.to_string(),
763 kind: StreamErrorKind::InvalidRequest,
764 });
765 return;
766 }
767 };
768 let thinking = thinking_config
769 .as_ref()
770 .map(ApiThinkingConfig::from_thinking_config);
771 let output_config = thinking_config
772 .as_ref()
773 .and_then(|t| t.effort)
774 .map(|effort| ApiOutputConfig { effort });
775
776 let system = self.build_system_prompt_for_request(&request.system, system_cache);
777 let max_tokens = self.effective_max_tokens(&request);
778 let tool_choice = request
779 .tool_choice
780 .as_ref()
781 .map(ApiToolChoice::from_tool_choice);
782
783 let api_request = ApiMessagesRequest {
784 model: Some(&self.model),
785 max_tokens,
786 system,
787 messages: &messages,
788 tools: tools.as_deref(),
789 tool_choice,
790 stream: true,
791 thinking,
792 output_config,
793 anthropic_version: None,
794 };
795
796 log::debug!("Anthropic streaming LLM request model={} max_tokens={} oauth={}", self.model, max_tokens, is_oauth);
797
798 if log::log_enabled!(log::Level::Debug) {
800 match serde_json::to_string_pretty(&api_request) {
801 Ok(json) => log::debug!("Anthropic streaming API request payload:\n{json}"),
802 Err(e) => log::debug!("Failed to serialize streaming request for logging: {e}"),
803 }
804 }
805
806 let builder = self
807 .client
808 .post(format!("{}/v1/messages", self.base_url))
809 .header("Content-Type", "application/json");
810 let response = match self
811 .apply_auth(builder)
812 .json(&api_request)
813 .send()
814 .await
815 {
816 Ok(r) => r,
817 Err(e) => {
818 yield Err(anyhow::anyhow!("request failed: {e}"));
819 return;
820 }
821 };
822
823 let status = response.status();
824
825 if status == StatusCode::TOO_MANY_REQUESTS {
826 yield Ok(StreamDelta::Error {
827 message: "Rate limited".to_string(),
828 kind: StreamErrorKind::RateLimited,
829 });
830 return;
831 }
832
833 if status.is_server_error() {
834 let body = response.text().await.unwrap_or_default();
835 log::error!("Anthropic server error status={status} body={body}");
836 yield Ok(StreamDelta::Error {
837 message: body,
838 kind: StreamErrorKind::ServerError,
839 });
840 return;
841 }
842
843 if status.is_client_error() {
844 let body = response.text().await.unwrap_or_default();
845 log::warn!("Anthropic client error status={status} body={body}");
846 yield Ok(StreamDelta::Error {
847 message: body,
848 kind: StreamErrorKind::InvalidRequest,
849 });
850 return;
851 }
852
853 let mut stream = response.bytes_stream();
855 let mut buffer = String::new();
856 let mut input_tokens: u32 = 0;
857 let mut output_tokens: u32 = 0;
858 let mut cached_input_tokens: u32 = 0;
859 let mut cache_creation_input_tokens: u32 = 0;
860 let mut tool_ids: std::collections::HashMap<usize, String> =
862 std::collections::HashMap::new();
863
864 let mut received_message_stop = false;
865 let mut stream_errored = false;
870 let mut pending_stop_reason: Option<agent_sdk_foundation::llm::StopReason> = None;
871 let mut chunk_count: u64 = 0;
872 let mut total_bytes: u64 = 0;
873
874 struct StreamDropGuard {
876 completed: bool,
877 chunk_count: u64,
878 }
879 impl Drop for StreamDropGuard {
880 fn drop(&mut self) {
881 if !self.completed {
882 log::debug!(
886 "SSE stream dropped before completion at chunk_count={} (task was likely cancelled)",
887 self.chunk_count
888 );
889 }
890 }
891 }
892 let mut drop_guard = StreamDropGuard { completed: false, chunk_count: 0 };
893
894 log::debug!("Starting SSE stream processing");
895
896 while let Some(chunk_result) = stream.next().await {
897 let chunk = match chunk_result {
898 Ok(c) => c,
899 Err(e) => {
900 log::error!("Stream error while reading chunk error={e} chunk_count={chunk_count} total_bytes={total_bytes}");
901 yield Err(anyhow::anyhow!("stream error: {e}"));
902 return;
903 }
904 };
905
906 chunk_count += 1;
907 total_bytes += chunk.len() as u64;
908 drop_guard.chunk_count = chunk_count;
909
910 if chunk_count.is_multiple_of(10) {
912 log::debug!("SSE chunk progress: chunk_count={chunk_count} total_bytes={total_bytes}");
913 }
914 buffer.push_str(&String::from_utf8_lossy(&chunk));
915
916 while let Some(event_block) = take_next_sse_event(&mut buffer) {
918 if is_message_stop_event(&event_block) {
920 log::debug!("Received message_stop event chunk_count={chunk_count} total_bytes={total_bytes}");
921 received_message_stop = true;
922 }
923
924 if let Some(mut delta) = parse_sse_event(
926 &event_block,
927 &mut input_tokens,
928 &mut output_tokens,
929 &mut cached_input_tokens,
930 &mut cache_creation_input_tokens,
931 &mut tool_ids,
932 &mut pending_stop_reason,
933 ) {
934 if is_oauth
936 && let StreamDelta::ToolUseStart { ref mut name, .. } = delta
937 {
938 *name = from_claude_code_name(name, &original_tool_names);
939 }
940 if matches!(delta, StreamDelta::Error { .. }) {
943 stream_errored = true;
944 }
945 yield Ok(delta);
946 }
947 if is_message_stop_event(&event_block) {
949 yield Ok(StreamDelta::Done {
950 stop_reason: pending_stop_reason.take(),
951 });
952 }
953 }
954 }
955
956 log::debug!(
957 "SSE stream ended chunk_count={chunk_count} total_bytes={total_bytes} buffer_remaining={} received_message_stop={received_message_stop}",
958 buffer.len()
959 );
960
961 let remaining = buffer.trim();
963 if !remaining.is_empty() {
964 log::debug!(
965 "Processing remaining buffer content remaining_len={} remaining_preview={}",
966 remaining.len(),
967 remaining.chars().take(100).collect::<String>()
968 );
969
970 if is_message_stop_event(remaining) {
972 received_message_stop = true;
973 }
974
975 if let Some(mut delta) = parse_sse_event(
976 remaining,
977 &mut input_tokens,
978 &mut output_tokens,
979 &mut cached_input_tokens,
980 &mut cache_creation_input_tokens,
981 &mut tool_ids,
982 &mut pending_stop_reason,
983 ) {
984 if is_oauth
985 && let StreamDelta::ToolUseStart { ref mut name, .. } = delta
986 {
987 *name = from_claude_code_name(name, &original_tool_names);
988 }
989 if matches!(delta, StreamDelta::Error { .. }) {
990 stream_errored = true;
991 }
992 yield Ok(delta);
993 }
994 if is_message_stop_event(remaining) {
996 yield Ok(StreamDelta::Done {
997 stop_reason: pending_stop_reason.take(),
998 });
999 }
1000 }
1001
1002 drop_guard.completed = true;
1004
1005 if !received_message_stop && !stream_errored {
1011 log::warn!(
1012 "SSE stream ended without message_stop event - stream may have been interrupted chunk_count={chunk_count} total_bytes={total_bytes}"
1013 );
1014 yield Ok(StreamDelta::Error {
1015 message: "Stream ended unexpectedly without completion".to_string(),
1016 kind: StreamErrorKind::ServerError,
1017 });
1018 }
1019 })
1020 }
1021
1022 fn validate_thinking_config(&self, thinking: Option<&ThinkingConfig>) -> Result<()> {
1023 let Some(thinking) = thinking else {
1024 return Ok(());
1025 };
1026
1027 if self
1028 .capabilities()
1029 .is_some_and(|caps| !caps.supports_thinking)
1030 {
1031 return Err(anyhow::anyhow!(
1032 "thinking is not supported for provider={} model={}",
1033 self.provider(),
1034 self.model()
1035 ));
1036 }
1037
1038 if matches!(thinking.mode, ThinkingMode::Adaptive)
1039 && !self
1040 .capabilities()
1041 .is_some_and(|caps| caps.supports_adaptive_thinking)
1042 {
1043 return Err(anyhow::anyhow!(
1044 "adaptive thinking is not supported for provider={} model={}",
1045 self.provider(),
1046 self.model()
1047 ));
1048 }
1049
1050 if self.requires_adaptive_thinking()
1051 && matches!(thinking.mode, ThinkingMode::Enabled { .. })
1052 {
1053 return Err(anyhow::anyhow!(
1054 "budget_tokens thinking is deprecated for provider={} model={}; use ThinkingConfig::adaptive() instead",
1055 self.provider(),
1056 self.model()
1057 ));
1058 }
1059
1060 Ok(())
1061 }
1062
1063 async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
1064 let mut models = Vec::new();
1068 let mut after_id: Option<String> = None;
1069 for _ in 0..MODELS_MAX_PAGES {
1070 let mut query: Vec<(&str, String)> = vec![("limit", MODELS_PAGE_LIMIT.to_string())];
1071 if let Some(after) = &after_id {
1072 query.push(("after_id", after.clone()));
1073 }
1074 let builder = self
1075 .client
1076 .get(format!("{}/v1/models", self.base_url))
1077 .header("Content-Type", "application/json")
1078 .query(&query);
1079 let builder = self.apply_auth(builder);
1080 let body =
1081 crate::impls::model_listing::fetch_model_list_body(builder, "Anthropic").await?;
1082 let page = parse_models_page(&body)?;
1083 models.extend(page.models);
1084 if !page.has_more {
1085 return Ok(models);
1086 }
1087 match page.last_id {
1088 Some(last) => after_id = Some(last),
1089 None => return Ok(models),
1091 }
1092 }
1093 Ok(models)
1094 }
1095
1096 fn model(&self) -> &str {
1097 &self.model
1098 }
1099
1100 fn provider(&self) -> &'static str {
1101 "anthropic"
1102 }
1103
1104 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
1105 self.thinking.as_ref()
1106 }
1107
1108 fn default_max_tokens(&self) -> u32 {
1109 let model_max = self
1110 .capabilities()
1111 .and_then(|caps| caps.max_output_tokens)
1112 .or_else(|| {
1113 crate::model_capabilities::default_max_output_tokens(self.provider(), self.model())
1114 })
1115 .unwrap_or(4096);
1116 model_max.clamp(4096, DEFAULT_SAFE_MAX_OUTPUT_TOKENS)
1117 }
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122 use super::*;
1123
1124 const ANTHROPIC_MODELS_FIXTURE: &str = r#"{
1125 "data": [
1126 {"type": "model", "id": "claude-opus-4-8", "display_name": "Claude Opus 4.8"},
1127 {"type": "model", "id": "claude-sonnet-4-5", "display_name": "Claude Sonnet 4.5"}
1128 ],
1129 "has_more": false
1130 }"#;
1131
1132 #[test]
1133 fn parse_models_page_reads_id_and_display_name() -> anyhow::Result<()> {
1134 let page = parse_models_page(ANTHROPIC_MODELS_FIXTURE)?;
1135 assert_eq!(page.models.len(), 2);
1136 assert_eq!(page.models[0].id, "claude-opus-4-8");
1137 assert_eq!(
1138 page.models[0].display_name.as_deref(),
1139 Some("Claude Opus 4.8")
1140 );
1141 assert_eq!(page.models[0].context_window, None);
1143 assert_eq!(page.models[0].max_output_tokens, None);
1144 assert!(!page.has_more);
1146 assert_eq!(page.last_id, None);
1147 Ok(())
1148 }
1149
1150 #[tokio::test]
1151 async fn list_models_follows_pagination_across_pages() -> anyhow::Result<()> {
1152 use wiremock::matchers::{method, path, query_param, query_param_is_missing};
1153 use wiremock::{Mock, MockServer, ResponseTemplate};
1154
1155 let server = MockServer::start().await;
1156
1157 Mock::given(method("GET"))
1160 .and(path("/v1/models"))
1161 .and(query_param_is_missing("after_id"))
1162 .respond_with(ResponseTemplate::new(200).set_body_string(
1163 r#"{
1164 "data": [
1165 {"type": "model", "id": "claude-opus-4-8", "display_name": "Opus"},
1166 {"type": "model", "id": "claude-sonnet-4-5", "display_name": "Sonnet"}
1167 ],
1168 "has_more": true,
1169 "last_id": "claude-sonnet-4-5"
1170 }"#,
1171 ))
1172 .mount(&server)
1173 .await;
1174
1175 Mock::given(method("GET"))
1177 .and(path("/v1/models"))
1178 .and(query_param("after_id", "claude-sonnet-4-5"))
1179 .respond_with(ResponseTemplate::new(200).set_body_string(
1180 r#"{
1181 "data": [
1182 {"type": "model", "id": "claude-haiku-4-5", "display_name": "Haiku"}
1183 ],
1184 "has_more": false,
1185 "last_id": "claude-haiku-4-5"
1186 }"#,
1187 ))
1188 .mount(&server)
1189 .await;
1190
1191 let provider = AnthropicProvider::new("test-key-not-a-secret", "claude-test")
1192 .with_base_url(server.uri());
1193 let models = provider.list_models().await?;
1194
1195 let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect();
1197 assert_eq!(
1198 ids,
1199 vec!["claude-opus-4-8", "claude-sonnet-4-5", "claude-haiku-4-5"]
1200 );
1201 Ok(())
1202 }
1203
1204 #[test]
1209 fn test_new_creates_provider_with_custom_model() {
1210 let provider = AnthropicProvider::new("test-api-key", "custom-model");
1211
1212 assert_eq!(provider.model(), "custom-model");
1213 assert_eq!(provider.provider(), "anthropic");
1214 }
1215
1216 #[test]
1217 fn test_haiku_factory_creates_haiku_provider() {
1218 let provider = AnthropicProvider::haiku("test-api-key".to_string());
1219
1220 assert_eq!(provider.model(), MODEL_HAIKU_45);
1221 assert_eq!(provider.provider(), "anthropic");
1222 }
1223
1224 #[test]
1225 fn test_only_anthropic_46_models_accept_adaptive_thinking() {
1226 let sonnet_46 = AnthropicProvider::sonnet_46("test-api-key".to_string());
1227 assert!(
1228 sonnet_46
1229 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1230 .is_ok()
1231 );
1232
1233 let sonnet_45 = AnthropicProvider::sonnet_45("test-api-key".to_string());
1234 let error = sonnet_45
1235 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1236 .unwrap_err();
1237 assert!(
1238 error
1239 .to_string()
1240 .contains("adaptive thinking is not supported")
1241 );
1242 }
1243
1244 #[test]
1245 fn test_anthropic_46_models_reject_budgeted_thinking() {
1246 let sonnet_46 = AnthropicProvider::sonnet_46("test-api-key".to_string());
1247 let error = sonnet_46
1248 .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1249 .unwrap_err();
1250 assert!(error.to_string().contains("ThinkingConfig::adaptive()"));
1251 }
1252
1253 #[test]
1254 fn test_opus_47_rejects_budgeted_thinking() {
1255 let opus_47 = AnthropicProvider::opus_47("test-api-key".to_string());
1260 let error = opus_47
1261 .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1262 .unwrap_err();
1263 assert!(
1264 error.to_string().contains("ThinkingConfig::adaptive()"),
1265 "expected migration hint, got: {error}"
1266 );
1267 }
1268
1269 #[test]
1270 fn test_opus_47_accepts_adaptive_thinking() {
1271 let opus_47 = AnthropicProvider::opus_47("test-api-key".to_string());
1272 assert!(
1273 opus_47
1274 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1275 .is_ok()
1276 );
1277 assert!(
1278 opus_47
1279 .validate_thinking_config(Some(&ThinkingConfig::adaptive_with_effort(
1280 agent_sdk_foundation::llm::Effort::High
1281 )))
1282 .is_ok()
1283 );
1284 }
1285
1286 #[test]
1287 fn test_opus_47_factory_creates_opus_47_provider() {
1288 let provider = AnthropicProvider::opus_47("test-api-key".to_string());
1289 assert_eq!(provider.model(), MODEL_OPUS_47);
1290 assert_eq!(provider.provider(), "anthropic");
1291 }
1292
1293 #[test]
1294 fn test_opus_48_rejects_budgeted_thinking() {
1295 let opus_48 = AnthropicProvider::opus_48("test-api-key".to_string());
1300 let error = opus_48
1301 .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1302 .unwrap_err();
1303 assert!(
1304 error.to_string().contains("ThinkingConfig::adaptive()"),
1305 "expected migration hint, got: {error}"
1306 );
1307 }
1308
1309 #[test]
1310 fn test_opus_48_accepts_adaptive_thinking() {
1311 let opus_48 = AnthropicProvider::opus_48("test-api-key".to_string());
1312 assert!(
1313 opus_48
1314 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1315 .is_ok()
1316 );
1317 assert!(
1318 opus_48
1319 .validate_thinking_config(Some(&ThinkingConfig::adaptive_with_effort(
1320 agent_sdk_foundation::llm::Effort::High
1321 )))
1322 .is_ok()
1323 );
1324 }
1325
1326 #[test]
1327 fn test_opus_48_factory_creates_opus_48_provider() {
1328 let provider = AnthropicProvider::opus_48("test-api-key".to_string());
1329 assert_eq!(provider.model(), MODEL_OPUS_48);
1330 assert_eq!(provider.provider(), "anthropic");
1331 }
1332
1333 #[test]
1334 fn test_sonnet_5_rejects_budgeted_thinking() {
1335 let sonnet_5 = AnthropicProvider::sonnet_5("test-api-key".to_string());
1338 let error = sonnet_5
1339 .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1340 .unwrap_err();
1341 assert!(
1342 error.to_string().contains("ThinkingConfig::adaptive()"),
1343 "expected migration hint, got: {error}"
1344 );
1345 }
1346
1347 #[test]
1348 fn test_sonnet_5_accepts_adaptive_thinking() {
1349 let sonnet_5 = AnthropicProvider::sonnet_5("test-api-key".to_string());
1350 assert!(
1351 sonnet_5
1352 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1353 .is_ok()
1354 );
1355 assert!(
1356 sonnet_5
1357 .validate_thinking_config(Some(&ThinkingConfig::adaptive_with_effort(
1358 agent_sdk_foundation::llm::Effort::High
1359 )))
1360 .is_ok()
1361 );
1362 }
1363
1364 #[test]
1365 fn test_fable_5_rejects_budgeted_thinking() {
1366 let fable = AnthropicProvider::fable("test-api-key".to_string());
1370 let error = fable
1371 .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1372 .unwrap_err();
1373 assert!(
1374 error.to_string().contains("ThinkingConfig::adaptive()"),
1375 "expected migration hint, got: {error}"
1376 );
1377 }
1378
1379 #[test]
1380 fn test_fable_5_accepts_adaptive_thinking() {
1381 let fable = AnthropicProvider::fable("test-api-key".to_string());
1382 assert!(
1383 fable
1384 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1385 .is_ok()
1386 );
1387 assert!(
1388 fable
1389 .validate_thinking_config(Some(&ThinkingConfig::adaptive_with_effort(
1390 agent_sdk_foundation::llm::Effort::High
1391 )))
1392 .is_ok()
1393 );
1394 }
1395
1396 #[test]
1397 fn test_fable_factory_creates_fable_5_provider() {
1398 let provider = AnthropicProvider::fable("test-api-key".to_string());
1399 assert_eq!(provider.model(), MODEL_FABLE_5);
1400 assert_eq!(provider.provider(), "anthropic");
1401 }
1402
1403 #[test]
1404 fn test_sonnet_factory_creates_sonnet_provider() {
1405 let provider = AnthropicProvider::sonnet("test-api-key".to_string());
1406
1407 assert_eq!(provider.model(), MODEL_SONNET_46);
1408 assert_eq!(provider.provider(), "anthropic");
1409 }
1410
1411 #[test]
1412 fn test_sonnet_45_factory_creates_sonnet_provider() {
1413 let provider = AnthropicProvider::sonnet_45("test-api-key".to_string());
1414
1415 assert_eq!(provider.model(), MODEL_SONNET_45);
1416 assert_eq!(provider.provider(), "anthropic");
1417 }
1418
1419 #[test]
1420 fn test_sonnet_46_factory_creates_sonnet_provider() {
1421 let provider = AnthropicProvider::sonnet_46("test-api-key".to_string());
1422
1423 assert_eq!(provider.model(), MODEL_SONNET_46);
1424 assert_eq!(provider.provider(), "anthropic");
1425 }
1426
1427 #[test]
1428 fn test_opus_factory_creates_opus_provider() {
1429 let provider = AnthropicProvider::opus("test-api-key".to_string());
1430
1431 assert_eq!(provider.model(), MODEL_OPUS_46);
1432 assert_eq!(provider.provider(), "anthropic");
1433 }
1434
1435 #[test]
1440 fn test_model_constants_have_expected_values() {
1441 assert!(MODEL_HAIKU_35.contains("haiku"));
1442 assert!(MODEL_SONNET_35.contains("sonnet"));
1443 assert!(MODEL_SONNET_4.contains("sonnet"));
1444 assert!(MODEL_SONNET_46.contains("sonnet"));
1445 assert!(MODEL_OPUS_4.contains("opus"));
1446 }
1447
1448 #[test]
1453 fn test_provider_is_cloneable() {
1454 let provider = AnthropicProvider::new("test-api-key", "test-model");
1455 let cloned = provider.clone();
1456
1457 assert_eq!(provider.model(), cloned.model());
1458 assert_eq!(provider.provider(), cloned.provider());
1459 }
1460
1461 fn tool(name: &str) -> agent_sdk_foundation::llm::Tool {
1466 agent_sdk_foundation::llm::Tool {
1467 name: name.to_string(),
1468 description: "desc".to_string(),
1469 input_schema: serde_json::json!({ "type": "object" }),
1470 display_name: name.to_string(),
1471 tier: agent_sdk_foundation::ToolTier::Observe,
1472 }
1473 }
1474
1475 fn request_with_tools(tools: Vec<agent_sdk_foundation::llm::Tool>) -> ChatRequest {
1476 ChatRequest {
1477 system: String::new(),
1478 messages: vec![agent_sdk_foundation::llm::Message::user("hi")],
1479 tools: Some(tools),
1480 max_tokens: 1024,
1481 max_tokens_explicit: true,
1482 session_id: None,
1483 cached_content: None,
1484 thinking: None,
1485 tool_choice: None,
1486 response_format: None,
1487 cache: None,
1488 }
1489 }
1490
1491 #[test]
1492 fn test_oauth_tool_name_collision_detects_case_variants() {
1493 let tools = vec![tool("task"), tool("Task")];
1494 let collision = oauth_tool_name_collision(Some(&tools));
1495 assert!(collision.is_some());
1496 }
1497
1498 #[test]
1499 fn test_oauth_tool_name_collision_allows_distinct_names() {
1500 let tools = vec![tool("read"), tool("write"), tool("Read_File")];
1501 assert!(oauth_tool_name_collision(Some(&tools)).is_none());
1502 assert!(oauth_tool_name_collision(None).is_none());
1503 }
1504
1505 #[tokio::test]
1506 async fn test_oauth_chat_rejects_case_colliding_tools() -> anyhow::Result<()> {
1507 let provider = AnthropicProvider::new("sk-ant-oat-test", MODEL_SONNET_45);
1509 assert!(provider.is_oauth());
1510 let request = request_with_tools(vec![tool("task"), tool("Task")]);
1511 let outcome = provider.chat(request).await?;
1512 match outcome {
1513 ChatOutcome::InvalidRequest(msg) => {
1514 assert!(msg.contains("collide case-insensitively"), "got: {msg}");
1515 }
1516 other => panic!("expected InvalidRequest, got {other:?}"),
1517 }
1518 Ok(())
1519 }
1520
1521 #[tokio::test]
1522 async fn test_api_key_chat_does_not_apply_oauth_collision_gate() -> anyhow::Result<()> {
1523 let provider = AnthropicProvider::new("sk-ant-api-test", MODEL_SONNET_45);
1528 assert!(!provider.is_oauth());
1529 let tools = vec![tool("task"), tool("Task")];
1530 assert!(oauth_tool_name_collision(Some(&tools)).is_some());
1532 Ok(())
1533 }
1534
1535 fn apply_auth_beta_header(provider: &AnthropicProvider) -> anyhow::Result<Option<String>> {
1540 let builder = reqwest::Client::new().post("http://localhost/v1/messages");
1541 let request = provider.apply_auth(builder).build()?;
1542 Ok(request
1543 .headers()
1544 .get("anthropic-beta")
1545 .and_then(|value| value.to_str().ok())
1546 .map(str::to_owned))
1547 }
1548
1549 #[test]
1550 fn api_key_auth_sends_interleaved_beta_for_budget_thinking_models() -> anyhow::Result<()> {
1551 for provider in [
1554 AnthropicProvider::sonnet_45("test-key-not-a-secret"),
1555 AnthropicProvider::haiku("test-key-not-a-secret"),
1556 ] {
1557 assert!(!provider.is_oauth());
1558 assert_eq!(
1559 apply_auth_beta_header(&provider)?.as_deref(),
1560 Some("interleaved-thinking-2025-05-14"),
1561 "expected interleaved beta for {}",
1562 provider.model()
1563 );
1564 }
1565 Ok(())
1566 }
1567
1568 #[test]
1569 fn api_key_auth_omits_interleaved_beta_for_adaptive_models() -> anyhow::Result<()> {
1570 for provider in [
1573 AnthropicProvider::opus_48("test-key-not-a-secret"),
1574 AnthropicProvider::sonnet_5("test-key-not-a-secret"),
1575 AnthropicProvider::fable("test-key-not-a-secret"),
1576 ] {
1577 assert!(!provider.is_oauth());
1578 assert_eq!(
1579 apply_auth_beta_header(&provider)?,
1580 None,
1581 "expected no beta header for adaptive model {}",
1582 provider.model()
1583 );
1584 }
1585 Ok(())
1586 }
1587
1588 async fn captured_request_body(
1597 provider: &AnthropicProvider,
1598 request: ChatRequest,
1599 server: &wiremock::MockServer,
1600 ) -> serde_json::Value {
1601 use wiremock::matchers::{method, path};
1602 use wiremock::{Mock, ResponseTemplate};
1603
1604 Mock::given(method("POST"))
1605 .and(path("/v1/messages"))
1606 .respond_with(ResponseTemplate::new(200).set_body_string("{}"))
1607 .mount(server)
1608 .await;
1609
1610 let _ = provider.chat(request).await;
1611
1612 let received = server
1613 .received_requests()
1614 .await
1615 .expect("mock server records requests");
1616 assert_eq!(received.len(), 1, "expected exactly one request");
1617 serde_json::from_slice(&received[0].body).expect("request body is JSON")
1618 }
1619
1620 #[tokio::test]
1621 async fn forced_tool_drops_configured_thinking_on_the_wire() {
1622 let server = wiremock::MockServer::start().await;
1629 let provider = AnthropicProvider::sonnet_45("sk-ant-api-test")
1630 .with_thinking(ThinkingConfig::new(10_000))
1631 .with_base_url(server.uri());
1632
1633 let mut request = request_with_tools(vec![tool("respond")]);
1634 request.tool_choice = Some(agent_sdk_foundation::llm::ToolChoice::Tool(
1635 "respond".to_owned(),
1636 ));
1637
1638 let body = captured_request_body(&provider, request, &server).await;
1639
1640 assert!(
1641 body.get("thinking").is_none(),
1642 "thinking must be absent when a tool is forced, got: {body}"
1643 );
1644 assert_eq!(
1645 body["tool_choice"]["type"], "tool",
1646 "the forced tool_choice must survive, got: {body}"
1647 );
1648 }
1649
1650 #[tokio::test]
1651 async fn configured_thinking_survives_without_forced_tool() {
1652 let server = wiremock::MockServer::start().await;
1657 let provider = AnthropicProvider::sonnet_45("sk-ant-api-test")
1658 .with_thinking(ThinkingConfig::new(10_000))
1659 .with_base_url(server.uri());
1660
1661 let mut request = request_with_tools(vec![tool("read")]);
1662 request.tool_choice = Some(agent_sdk_foundation::llm::ToolChoice::Auto);
1663
1664 let body = captured_request_body(&provider, request, &server).await;
1665
1666 assert_eq!(
1667 body["thinking"]["type"], "enabled",
1668 "configured thinking must survive when no tool is forced, got: {body}"
1669 );
1670 }
1671}