1pub(crate) mod data;
8
9use crate::attachments::validate_request_attachments;
10use crate::provider::{LlmProvider, thinking_for_forced_tool};
11use crate::streaming::{
12 StreamBox, StreamDelta, StreamErrorKind, reqwest_body_error_delta, reqwest_error_delta,
13};
14use agent_sdk_foundation::llm::{
15 CacheTtl, ChatOutcome, ChatRequest, ChatResponse, ContentBlock, ThinkingConfig, ThinkingMode,
16 Usage,
17};
18use anyhow::Result;
19use async_trait::async_trait;
20use data::{
21 ApiMessagesRequest, ApiOutputConfig, ApiThinkingConfig, ApiToolChoice, build_api_messages,
22 build_api_tools_with_cache, is_message_stop_event, map_content_blocks, map_stop_reason,
23 parse_sse_event, take_next_sse_event,
24};
25use futures::StreamExt;
26use reqwest::StatusCode;
27
28const API_BASE_URL: &str = "https://api.anthropic.com";
29const API_VERSION: &str = "2023-06-01";
30const CLAUDE_CODE_VERSION: &str = "2.1.75";
31const DEFAULT_SAFE_MAX_OUTPUT_TOKENS: u32 = 32_000;
32const MODELS_PAGE_LIMIT: u32 = 1000;
34const MODELS_MAX_PAGES: usize = 100;
38const STREAM_HEADERS_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(1);
51const SSE_BYTE_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90);
61const CHAT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(5);
65const POOL_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
69
70pub const MODEL_HAIKU_35: &str = "claude-3-5-haiku-20241022";
71pub const MODEL_SONNET_35: &str = "claude-3-5-sonnet-20241022";
72pub const MODEL_SONNET_4: &str = "claude-sonnet-4-20250514";
73pub const MODEL_OPUS_4: &str = "claude-opus-4-20250514";
74
75pub const MODEL_HAIKU_45: &str = "claude-haiku-4-5-20251001";
76pub const MODEL_SONNET_45: &str = "claude-sonnet-4-5-20250929";
77pub const MODEL_SONNET_46: &str = "claude-sonnet-4-6";
78pub const MODEL_SONNET_5: &str = "claude-sonnet-5";
79pub const MODEL_OPUS_46: &str = "claude-opus-4-6";
80pub const MODEL_OPUS_47: &str = "claude-opus-4-7";
81pub const MODEL_OPUS_48: &str = "claude-opus-4-8";
82pub const MODEL_FABLE_5: &str = "claude-fable-5";
83
84const CLAUDE_CODE_TOOLS: &[&str] = &[
91 "Read",
92 "Write",
93 "Edit",
94 "Bash",
95 "Grep",
96 "Glob",
97 "AskUserQuestion",
98 "EnterPlanMode",
99 "ExitPlanMode",
100 "KillShell",
101 "NotebookEdit",
102 "Skill",
103 "Task",
104 "TaskOutput",
105 "TodoWrite",
106 "WebFetch",
107 "WebSearch",
108];
109
110fn to_claude_code_name(name: &str) -> String {
112 let lower = name.to_lowercase();
113 for cc_name in CLAUDE_CODE_TOOLS {
114 if cc_name.to_lowercase() == lower {
115 return (*cc_name).to_string();
116 }
117 }
118 name.to_string()
119}
120
121fn from_claude_code_name(name: &str, original_names: &[String]) -> String {
123 let lower = name.to_lowercase();
124 for original in original_names {
125 if original.to_lowercase() == lower {
126 return original.clone();
127 }
128 }
129 name.to_string()
130}
131
132fn oauth_tool_name_collision(
140 tools: Option<&[agent_sdk_foundation::llm::Tool]>,
141) -> Option<(String, String)> {
142 let tools = tools?;
143 for (index, tool) in tools.iter().enumerate() {
144 for other in &tools[index + 1..] {
145 if tool.name != other.name && tool.name.eq_ignore_ascii_case(&other.name) {
146 return Some((tool.name.clone(), other.name.clone()));
147 }
148 }
149 }
150 None
151}
152
153fn oauth_tool_collision_message(first: &str, second: &str) -> String {
154 format!(
155 "OAuth tool names collide case-insensitively: '{first}' and '{second}' would map to the same Claude Code tool name; rename one to disambiguate"
156 )
157}
158
159#[must_use]
161pub fn is_oauth_token(api_key: &str) -> bool {
162 api_key.starts_with("sk-ant-oat")
163}
164
165struct AnthropicModelsPage {
168 models: Vec<crate::provider::ModelInfo>,
169 has_more: bool,
170 last_id: Option<String>,
171}
172
173fn parse_models_page(body: &str) -> Result<AnthropicModelsPage> {
180 #[derive(serde::Deserialize)]
181 struct ListResponse {
182 #[serde(default)]
183 data: Vec<ModelRow>,
184 #[serde(default)]
185 has_more: bool,
186 #[serde(default)]
187 last_id: Option<String>,
188 }
189 #[derive(serde::Deserialize)]
190 struct ModelRow {
191 id: String,
192 #[serde(default)]
193 display_name: Option<String>,
194 }
195 let parsed: ListResponse = serde_json::from_str(body)
196 .map_err(|e| anyhow::anyhow!("failed to parse Anthropic models list: {e}"))?;
197 let models = parsed
198 .data
199 .into_iter()
200 .map(|row| crate::provider::ModelInfo {
201 id: row.id,
202 display_name: row.display_name,
203 context_window: None,
204 max_output_tokens: None,
205 })
206 .collect();
207 Ok(AnthropicModelsPage {
208 models,
209 has_more: parsed.has_more,
210 last_id: parsed.last_id,
211 })
212}
213
214struct CacheRegions {
218 tools: Option<data::ApiCacheControl>,
219 system: Option<data::ApiCacheControl>,
220 messages: Option<data::ApiCacheControl>,
221}
222
223impl CacheRegions {
224 const DISABLED: Self = Self {
226 tools: None,
227 system: None,
228 messages: None,
229 };
230}
231
232#[derive(Clone, Debug)]
234enum AuthMode {
235 ApiKey,
237 OAuth,
239}
240
241#[derive(Clone)]
243pub struct AnthropicProvider {
244 client: reqwest::Client,
245 api_key: String,
246 model: String,
247 base_url: String,
248 auth_mode: AuthMode,
249 thinking: Option<ThinkingConfig>,
250 extra_headers: Vec<(String, String)>,
252 stream_headers_timeout: std::time::Duration,
254 sse_byte_idle_timeout: std::time::Duration,
256}
257
258impl AnthropicProvider {
259 pub const API_KEY_ENV: &'static str = "ANTHROPIC_API_KEY";
261
262 #[must_use]
267 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
268 let api_key = api_key.into();
269 let model = model.into();
270 let auth_mode = if is_oauth_token(&api_key) {
271 AuthMode::OAuth
272 } else {
273 AuthMode::ApiKey
274 };
275
276 let client = reqwest::Client::builder()
283 .connect_timeout(std::time::Duration::from_secs(30))
284 .tcp_keepalive(std::time::Duration::from_secs(30))
285 .pool_idle_timeout(POOL_IDLE_TIMEOUT)
286 .build()
287 .unwrap_or_else(|error| {
288 log::error!(
291 "failed to build Anthropic HTTP client with timeouts, \
292 falling back to reqwest defaults: {error}"
293 );
294 reqwest::Client::default()
295 });
296
297 Self {
298 client,
299 api_key,
300 model,
301 base_url: API_BASE_URL.to_owned(),
302 auth_mode,
303 thinking: None,
304 extra_headers: Vec::new(),
305 stream_headers_timeout: STREAM_HEADERS_TIMEOUT,
306 sse_byte_idle_timeout: SSE_BYTE_IDLE_TIMEOUT,
307 }
308 }
309
310 #[must_use]
312 pub const fn is_oauth(&self) -> bool {
313 matches!(self.auth_mode, AuthMode::OAuth)
314 }
315
316 fn apply_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
322 let builder = if self.api_key.is_empty() {
323 builder.header("anthropic-version", API_VERSION)
324 } else {
325 match self.auth_mode {
326 AuthMode::ApiKey => {
327 let builder = builder
328 .header("x-api-key", &self.api_key)
329 .header("anthropic-version", API_VERSION);
330 if self.requires_adaptive_thinking() {
338 builder
339 } else {
340 builder.header("anthropic-beta", "interleaved-thinking-2025-05-14")
341 }
342 }
343 AuthMode::OAuth => {
344 let mut beta_features = vec![
348 "claude-code-20250219",
349 "oauth-2025-04-20",
350 "fine-grained-tool-streaming-2025-05-14",
351 ];
352 if !self.requires_adaptive_thinking() {
353 beta_features.push("interleaved-thinking-2025-05-14");
354 }
355 builder
356 .header("Authorization", format!("Bearer {}", self.api_key))
357 .header("anthropic-version", API_VERSION)
358 .header("anthropic-beta", beta_features.join(","))
359 .header("user-agent", format!("claude-cli/{CLAUDE_CODE_VERSION}"))
360 .header("x-app", "cli")
361 }
362 }
363 };
364 self.extra_headers
365 .iter()
366 .fold(builder, |b, (k, v)| b.header(k.as_str(), v.as_str()))
367 }
368
369 const OAUTH_IDENTITY: &'static str =
370 "You are Claude Code, Anthropic's official CLI for Claude.";
371
372 fn build_system_prompt_for_request<'a>(
378 &self,
379 system: &'a str,
380 cache_control: Option<data::ApiCacheControl>,
381 ) -> Option<data::ApiSystemPrompt<'a>> {
382 match self.auth_mode {
383 AuthMode::ApiKey => data::build_api_system_prompt(system, cache_control),
384 AuthMode::OAuth => {
385 let mut blocks = vec![data::ApiSystemBlock {
386 block_type: "text",
387 text: Self::OAUTH_IDENTITY,
388 cache_control: cache_control.clone(),
389 }];
390 if !system.is_empty() {
391 blocks.push(data::ApiSystemBlock {
392 block_type: "text",
393 text: system,
394 cache_control,
395 });
396 }
397 Some(data::ApiSystemPrompt::Blocks(blocks))
398 }
399 }
400 }
401
402 fn cache_regions(request: &ChatRequest) -> CacheRegions {
411 let (enabled, ttl, max_breakpoints) =
412 request.cache.as_ref().map_or((true, None, None), |cfg| {
413 (cfg.enabled, cfg.ttl, cfg.max_breakpoints)
414 });
415 if !enabled {
416 return CacheRegions::DISABLED;
417 }
418 let control = data::ApiCacheControl::ephemeral_with_ttl(ttl.map(CacheTtl::as_wire_str));
419 let limit = max_breakpoints.unwrap_or(u8::MAX);
420 CacheRegions {
421 tools: (limit >= 1).then(|| control.clone()),
422 system: (limit >= 2).then(|| control.clone()),
423 messages: (limit >= 3).then_some(control),
424 }
425 }
426
427 fn build_cached_api_messages(
428 request: &ChatRequest,
429 cache_control: Option<data::ApiCacheControl>,
430 ) -> Vec<data::ApiMessage> {
431 let mut messages = build_api_messages(request);
432 if let Some(cache_control) = cache_control {
433 data::apply_cache_control_to_last_user_message(&mut messages, cache_control);
434 }
435 messages
436 }
437
438 fn effective_max_tokens(&self, request: &ChatRequest) -> u32 {
439 if request.max_tokens_explicit {
440 request.max_tokens
441 } else {
442 self.default_max_tokens()
443 }
444 }
445
446 #[must_use]
459 pub fn from_env() -> Self {
460 Self::try_from_env().unwrap_or_else(|e| panic!("{e}"))
461 }
462
463 pub fn try_from_env() -> Result<Self> {
471 let api_key = std::env::var(Self::API_KEY_ENV).map_err(|_| {
472 anyhow::anyhow!("environment variable `{}` is not set", Self::API_KEY_ENV)
473 })?;
474 Ok(Self::sonnet(api_key))
475 }
476
477 #[must_use]
479 pub fn haiku(api_key: impl Into<String>) -> Self {
480 Self::new(api_key, MODEL_HAIKU_45)
481 }
482
483 #[must_use]
485 pub fn sonnet(api_key: impl Into<String>) -> Self {
486 Self::new(api_key, MODEL_SONNET_46)
487 }
488
489 #[must_use]
491 pub fn sonnet_45(api_key: impl Into<String>) -> Self {
492 Self::new(api_key, MODEL_SONNET_45)
493 }
494
495 #[must_use]
497 pub fn sonnet_46(api_key: impl Into<String>) -> Self {
498 Self::new(api_key, MODEL_SONNET_46)
499 }
500
501 #[must_use]
503 pub fn opus(api_key: impl Into<String>) -> Self {
504 Self::new(api_key, MODEL_OPUS_46)
505 }
506
507 #[must_use]
514 pub fn opus_47(api_key: impl Into<String>) -> Self {
515 Self::new(api_key, MODEL_OPUS_47)
516 }
517
518 #[must_use]
525 pub fn opus_48(api_key: impl Into<String>) -> Self {
526 Self::new(api_key, MODEL_OPUS_48)
527 }
528
529 #[must_use]
538 pub fn fable(api_key: impl Into<String>) -> Self {
539 Self::new(api_key, MODEL_FABLE_5)
540 }
541
542 #[must_use]
545 pub fn sonnet_5(api_key: impl Into<String>) -> Self {
546 Self::new(api_key, MODEL_SONNET_5)
547 }
548
549 #[must_use]
551 pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
552 self.thinking = Some(thinking);
553 self
554 }
555
556 #[must_use]
558 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
559 self.base_url = base_url.into();
560 self
561 }
562
563 #[must_use]
569 pub const fn with_stream_stall_timeouts(
570 mut self,
571 headers_timeout: std::time::Duration,
572 byte_idle_timeout: std::time::Duration,
573 ) -> Self {
574 self.stream_headers_timeout = headers_timeout;
575 self.sse_byte_idle_timeout = byte_idle_timeout;
576 self
577 }
578
579 #[must_use]
581 pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
582 self.extra_headers = headers;
583 self
584 }
585
586 fn requires_adaptive_thinking(&self) -> bool {
587 matches!(
588 self.model.as_str(),
589 MODEL_SONNET_46
590 | MODEL_SONNET_5
591 | MODEL_OPUS_46
592 | MODEL_OPUS_47
593 | MODEL_OPUS_48
594 | MODEL_FABLE_5
595 )
596 }
597}
598
599#[async_trait]
600#[allow(clippy::too_many_lines)]
601impl LlmProvider for AnthropicProvider {
602 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
603 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
604 Ok(thinking) => thinking_for_forced_tool(thinking, request.tool_choice.as_ref()),
608 Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
609 };
610 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
611 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
612 }
613 if self.is_oauth()
614 && let Some((first, second)) = oauth_tool_name_collision(request.tools.as_deref())
615 {
616 return Ok(ChatOutcome::InvalidRequest(oauth_tool_collision_message(
617 &first, &second,
618 )));
619 }
620 let CacheRegions {
621 tools: tools_cache,
622 system: system_cache,
623 messages: messages_cache,
624 } = Self::cache_regions(&request);
625 let messages = Self::build_cached_api_messages(&request, messages_cache);
626 let tools = if self.is_oauth() {
627 build_api_tools_with_cache(&request, tools_cache).map(|tools| {
628 tools
629 .into_iter()
630 .map(|mut t| {
631 t.name = to_claude_code_name(&t.name);
632 t
633 })
634 .collect::<Vec<_>>()
635 })
636 } else {
637 build_api_tools_with_cache(&request, tools_cache)
638 };
639 let thinking = thinking_config
640 .as_ref()
641 .map(ApiThinkingConfig::from_thinking_config);
642 let output_config = thinking_config
643 .as_ref()
644 .and_then(|t| t.effort)
645 .map(|effort| ApiOutputConfig { effort });
646
647 let system = self.build_system_prompt_for_request(&request.system, system_cache);
648 let max_tokens = self.effective_max_tokens(&request);
649 let tool_choice = request
650 .tool_choice
651 .as_ref()
652 .map(ApiToolChoice::from_tool_choice);
653
654 let api_request = ApiMessagesRequest {
655 model: Some(&self.model),
656 max_tokens,
657 system,
658 messages: &messages,
659 tools: tools.as_deref(),
660 tool_choice,
661 stream: false,
662 thinking,
663 output_config,
664 anthropic_version: None,
665 };
666
667 log::debug!(
668 "Anthropic LLM request model={} max_tokens={} oauth={}",
669 self.model,
670 max_tokens,
671 self.is_oauth()
672 );
673
674 if log::log_enabled!(log::Level::Debug) {
676 match serde_json::to_string_pretty(&api_request) {
677 Ok(json) => log::debug!("Anthropic API request payload:\n{json}"),
678 Err(e) => log::debug!("Failed to serialize request for logging: {e}"),
679 }
680 }
681
682 let builder = self
687 .client
688 .post(format!("{}/v1/messages", self.base_url))
689 .timeout(CHAT_REQUEST_TIMEOUT)
690 .header("Content-Type", "application/json");
691 let response = self
692 .apply_auth(builder)
693 .json(&api_request)
694 .send()
695 .await
696 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
697
698 let status = response.status();
699 let retry_after = if status == StatusCode::TOO_MANY_REQUESTS {
702 crate::http::retry_after_from_headers(response.headers())
703 } else {
704 None
705 };
706 let bytes = response
707 .bytes()
708 .await
709 .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
710
711 log::debug!(
712 "Anthropic LLM response status={} body_len={}",
713 status,
714 bytes.len()
715 );
716
717 if status == StatusCode::TOO_MANY_REQUESTS {
718 return Ok(ChatOutcome::RateLimited(retry_after));
719 }
720
721 if status.is_server_error() {
722 let body = String::from_utf8_lossy(&bytes);
723 log::error!("Anthropic server error status={status} body={body}");
724 return Ok(ChatOutcome::ServerError(body.into_owned()));
725 }
726
727 if status.is_client_error() {
728 let body = String::from_utf8_lossy(&bytes);
729 log::warn!("Anthropic client error status={status} body={body}");
730 return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
731 }
732
733 let api_response: data::ApiResponse = serde_json::from_slice(&bytes)
734 .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
735
736 log::debug!(
738 "Anthropic API response: id={} model={} stop_reason={:?} usage={{input_tokens={}, output_tokens={}}} content_blocks={}",
739 api_response.id,
740 api_response.model,
741 api_response.stop_reason,
742 api_response.usage.total_input_tokens(),
743 api_response.usage.output,
744 api_response.content.len()
745 );
746
747 let mut content = map_content_blocks(api_response.content);
748
749 if self.is_oauth() {
751 let original_names: Vec<String> = request
752 .tools
753 .as_ref()
754 .map(|ts| ts.iter().map(|t| t.name.clone()).collect())
755 .unwrap_or_default();
756 for block in &mut content {
757 if let ContentBlock::ToolUse { name, .. } = block {
758 *name = from_claude_code_name(name, &original_names);
759 }
760 }
761 }
762
763 let stop_reason = api_response.stop_reason.as_ref().map(map_stop_reason);
764
765 Ok(ChatOutcome::Success(ChatResponse {
766 id: api_response.id,
767 content,
768 model: api_response.model,
769 stop_reason,
770 usage: Usage {
771 input_tokens: api_response.usage.total_input_tokens(),
772 output_tokens: api_response.usage.output,
773 cached_input_tokens: api_response.usage.cached_input_tokens(),
774 cache_creation_input_tokens: api_response.usage.cache_creation_input_tokens(),
775 },
776 }))
777 }
778
779 fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
780 Box::pin(async_stream::stream! {
781 let is_oauth = self.is_oauth();
782 let original_tool_names: Vec<String> = request
783 .tools
784 .as_ref()
785 .map(|ts| ts.iter().map(|t| t.name.clone()).collect())
786 .unwrap_or_default();
787
788 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
789 yield Ok(StreamDelta::Error {
790 message: error.to_string(),
791 kind: StreamErrorKind::InvalidRequest,
792 });
793 return;
794 }
795
796 if is_oauth
797 && let Some((first, second)) = oauth_tool_name_collision(request.tools.as_deref())
798 {
799 yield Ok(StreamDelta::Error {
800 message: oauth_tool_collision_message(&first, &second),
801 kind: StreamErrorKind::InvalidRequest,
802 });
803 return;
804 }
805
806 let CacheRegions {
807 tools: tools_cache,
808 system: system_cache,
809 messages: messages_cache,
810 } = Self::cache_regions(&request);
811 let messages = Self::build_cached_api_messages(&request, messages_cache);
812 let tools = if is_oauth {
813 build_api_tools_with_cache(&request, tools_cache).map(|tools| {
814 tools
815 .into_iter()
816 .map(|mut t| {
817 t.name = to_claude_code_name(&t.name);
818 t
819 })
820 .collect::<Vec<_>>()
821 })
822 } else {
823 build_api_tools_with_cache(&request, tools_cache)
824 };
825 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
826 Ok(thinking) => thinking_for_forced_tool(thinking, request.tool_choice.as_ref()),
831 Err(error) => {
832 yield Ok(StreamDelta::Error {
833 message: error.to_string(),
834 kind: StreamErrorKind::InvalidRequest,
835 });
836 return;
837 }
838 };
839 let thinking = thinking_config
840 .as_ref()
841 .map(ApiThinkingConfig::from_thinking_config);
842 let output_config = thinking_config
843 .as_ref()
844 .and_then(|t| t.effort)
845 .map(|effort| ApiOutputConfig { effort });
846
847 let system = self.build_system_prompt_for_request(&request.system, system_cache);
848 let max_tokens = self.effective_max_tokens(&request);
849 let tool_choice = request
850 .tool_choice
851 .as_ref()
852 .map(ApiToolChoice::from_tool_choice);
853
854 let api_request = ApiMessagesRequest {
855 model: Some(&self.model),
856 max_tokens,
857 system,
858 messages: &messages,
859 tools: tools.as_deref(),
860 tool_choice,
861 stream: true,
862 thinking,
863 output_config,
864 anthropic_version: None,
865 };
866
867 log::debug!("Anthropic streaming LLM request model={} max_tokens={} oauth={}", self.model, max_tokens, is_oauth);
868
869 if log::log_enabled!(log::Level::Debug) {
871 match serde_json::to_string_pretty(&api_request) {
872 Ok(json) => log::debug!("Anthropic streaming API request payload:\n{json}"),
873 Err(e) => log::debug!("Failed to serialize streaming request for logging: {e}"),
874 }
875 }
876
877 let builder = self
878 .client
879 .post(format!("{}/v1/messages", self.base_url))
880 .header("Content-Type", "application/json");
881 let headers_timeout = self.stream_headers_timeout;
890 let send = self.apply_auth(builder).json(&api_request).send();
891 let response = match tokio::time::timeout(headers_timeout, send).await {
892 Ok(Ok(r)) => r,
893 Ok(Err(error)) => {
894 yield Ok(reqwest_error_delta("request failed", &error));
895 return;
896 }
897 Err(_elapsed) => {
898 log::error!(
899 "Anthropic streaming request timed out awaiting response headers after {}s — stalled connection",
900 headers_timeout.as_secs()
901 );
902 yield Ok(StreamDelta::Error {
903 message: format!(
904 "request timed out awaiting response headers after {}s",
905 headers_timeout.as_secs()
906 ),
907 kind: StreamErrorKind::ConnectionLost,
908 });
909 return;
910 }
911 };
912
913 let status = response.status();
914
915 if status == StatusCode::TOO_MANY_REQUESTS {
916 let retry_after = crate::http::retry_after_from_headers(response.headers());
917 yield Ok(StreamDelta::Error {
918 message: "Rate limited".to_string(),
919 kind: StreamErrorKind::RateLimited(retry_after),
920 });
921 return;
922 }
923
924 if status.is_server_error() {
925 let body = response.text().await.unwrap_or_default();
926 log::error!("Anthropic server error status={status} body={body}");
927 yield Ok(StreamDelta::Error {
928 message: body,
929 kind: StreamErrorKind::ServerError,
930 });
931 return;
932 }
933
934 if status.is_client_error() {
935 let body = response.text().await.unwrap_or_default();
936 log::warn!("Anthropic client error status={status} body={body}");
937 yield Ok(StreamDelta::Error {
938 message: body,
939 kind: StreamErrorKind::InvalidRequest,
940 });
941 return;
942 }
943
944 let mut stream = response.bytes_stream();
946 let mut buffer = String::new();
947 let mut input_tokens: u32 = 0;
948 let mut output_tokens: u32 = 0;
949 let mut cached_input_tokens: u32 = 0;
950 let mut cache_creation_input_tokens: u32 = 0;
951 let mut tool_ids: std::collections::HashMap<usize, String> =
953 std::collections::HashMap::new();
954
955 let mut received_message_stop = false;
956 let mut stream_errored = false;
961 let mut pending_stop_reason: Option<agent_sdk_foundation::llm::StopReason> = None;
962 let mut chunk_count: u64 = 0;
963 let mut total_bytes: u64 = 0;
964
965 struct StreamDropGuard {
967 completed: bool,
968 chunk_count: u64,
969 }
970 impl Drop for StreamDropGuard {
971 fn drop(&mut self) {
972 if !self.completed {
973 log::debug!(
977 "SSE stream dropped before completion at chunk_count={} (task was likely cancelled)",
978 self.chunk_count
979 );
980 }
981 }
982 }
983 let mut drop_guard = StreamDropGuard { completed: false, chunk_count: 0 };
984
985 log::debug!("Starting SSE stream processing");
986
987 let byte_idle_timeout = self.sse_byte_idle_timeout;
992 loop {
993 let next = match tokio::time::timeout(byte_idle_timeout, stream.next()).await {
994 Ok(next) => next,
995 Err(_elapsed) => {
996 log::error!(
997 "SSE stream timed out: no bytes for {}s chunk_count={chunk_count} total_bytes={total_bytes} — stalled connection",
998 byte_idle_timeout.as_secs()
999 );
1000 yield Ok(StreamDelta::Error {
1001 message: format!(
1002 "SSE stream timed out: no bytes for {}s",
1003 byte_idle_timeout.as_secs()
1004 ),
1005 kind: StreamErrorKind::ConnectionLost,
1006 });
1007 return;
1008 }
1009 };
1010 let Some(chunk_result) = next else { break };
1011 let chunk = match chunk_result {
1012 Ok(c) => c,
1013 Err(error) => {
1014 log::error!("Stream error while reading chunk error={error} chunk_count={chunk_count} total_bytes={total_bytes}");
1015 yield Ok(reqwest_body_error_delta("stream error", &error));
1016 return;
1017 }
1018 };
1019
1020 chunk_count += 1;
1021 total_bytes += chunk.len() as u64;
1022 drop_guard.chunk_count = chunk_count;
1023
1024 if chunk_count.is_multiple_of(10) {
1026 log::debug!("SSE chunk progress: chunk_count={chunk_count} total_bytes={total_bytes}");
1027 }
1028 buffer.push_str(&String::from_utf8_lossy(&chunk));
1029
1030 while let Some(event_block) = take_next_sse_event(&mut buffer) {
1032 if is_message_stop_event(&event_block) {
1034 log::debug!("Received message_stop event chunk_count={chunk_count} total_bytes={total_bytes}");
1035 received_message_stop = true;
1036 }
1037
1038 if let Some(mut delta) = parse_sse_event(
1040 &event_block,
1041 &mut input_tokens,
1042 &mut output_tokens,
1043 &mut cached_input_tokens,
1044 &mut cache_creation_input_tokens,
1045 &mut tool_ids,
1046 &mut pending_stop_reason,
1047 ) {
1048 if is_oauth
1050 && let StreamDelta::ToolUseStart { ref mut name, .. } = delta
1051 {
1052 *name = from_claude_code_name(name, &original_tool_names);
1053 }
1054 if matches!(delta, StreamDelta::Error { .. }) {
1057 stream_errored = true;
1058 }
1059 yield Ok(delta);
1060 }
1061 if is_message_stop_event(&event_block) {
1063 yield Ok(StreamDelta::Done {
1064 stop_reason: pending_stop_reason.take(),
1065 });
1066 }
1067 }
1068 }
1069
1070 log::debug!(
1071 "SSE stream ended chunk_count={chunk_count} total_bytes={total_bytes} buffer_remaining={} received_message_stop={received_message_stop}",
1072 buffer.len()
1073 );
1074
1075 let remaining = buffer.trim();
1077 if !remaining.is_empty() {
1078 log::debug!(
1079 "Processing remaining buffer content remaining_len={} remaining_preview={}",
1080 remaining.len(),
1081 remaining.chars().take(100).collect::<String>()
1082 );
1083
1084 if is_message_stop_event(remaining) {
1086 received_message_stop = true;
1087 }
1088
1089 if let Some(mut delta) = parse_sse_event(
1090 remaining,
1091 &mut input_tokens,
1092 &mut output_tokens,
1093 &mut cached_input_tokens,
1094 &mut cache_creation_input_tokens,
1095 &mut tool_ids,
1096 &mut pending_stop_reason,
1097 ) {
1098 if is_oauth
1099 && let StreamDelta::ToolUseStart { ref mut name, .. } = delta
1100 {
1101 *name = from_claude_code_name(name, &original_tool_names);
1102 }
1103 if matches!(delta, StreamDelta::Error { .. }) {
1104 stream_errored = true;
1105 }
1106 yield Ok(delta);
1107 }
1108 if is_message_stop_event(remaining) {
1110 yield Ok(StreamDelta::Done {
1111 stop_reason: pending_stop_reason.take(),
1112 });
1113 }
1114 }
1115
1116 drop_guard.completed = true;
1118
1119 if !received_message_stop && !stream_errored {
1125 log::warn!(
1126 "SSE stream ended without message_stop event - stream may have been interrupted chunk_count={chunk_count} total_bytes={total_bytes}"
1127 );
1128 yield Ok(StreamDelta::Error {
1129 message: "Stream ended unexpectedly without completion".to_string(),
1130 kind: StreamErrorKind::ServerError,
1131 });
1132 }
1133 })
1134 }
1135
1136 fn validate_thinking_config(&self, thinking: Option<&ThinkingConfig>) -> Result<()> {
1137 let Some(thinking) = thinking else {
1138 return Ok(());
1139 };
1140
1141 if self
1142 .capabilities()
1143 .is_some_and(|caps| !caps.supports_thinking)
1144 {
1145 return Err(anyhow::anyhow!(
1146 "thinking is not supported for provider={} model={}",
1147 self.provider(),
1148 self.model()
1149 ));
1150 }
1151
1152 if matches!(thinking.mode, ThinkingMode::Adaptive)
1153 && !self
1154 .capabilities()
1155 .is_some_and(|caps| caps.supports_adaptive_thinking)
1156 {
1157 return Err(anyhow::anyhow!(
1158 "adaptive thinking is not supported for provider={} model={}",
1159 self.provider(),
1160 self.model()
1161 ));
1162 }
1163
1164 if self.requires_adaptive_thinking()
1165 && matches!(thinking.mode, ThinkingMode::Enabled { .. })
1166 {
1167 return Err(anyhow::anyhow!(
1168 "budget_tokens thinking is deprecated for provider={} model={}; use ThinkingConfig::adaptive() instead",
1169 self.provider(),
1170 self.model()
1171 ));
1172 }
1173
1174 Ok(())
1175 }
1176
1177 async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
1178 let mut models = Vec::new();
1182 let mut after_id: Option<String> = None;
1183 for _ in 0..MODELS_MAX_PAGES {
1184 let mut query: Vec<(&str, String)> = vec![("limit", MODELS_PAGE_LIMIT.to_string())];
1185 if let Some(after) = &after_id {
1186 query.push(("after_id", after.clone()));
1187 }
1188 let builder = self
1189 .client
1190 .get(format!("{}/v1/models", self.base_url))
1191 .header("Content-Type", "application/json")
1192 .query(&query);
1193 let builder = self.apply_auth(builder);
1194 let body =
1195 crate::impls::model_listing::fetch_model_list_body(builder, "Anthropic").await?;
1196 let page = parse_models_page(&body)?;
1197 models.extend(page.models);
1198 if !page.has_more {
1199 return Ok(models);
1200 }
1201 match page.last_id {
1202 Some(last) => after_id = Some(last),
1203 None => return Ok(models),
1205 }
1206 }
1207 Ok(models)
1208 }
1209
1210 async fn probe_connectivity(&self) -> bool {
1211 crate::provider::probe_http_reachability(&self.client, &self.base_url).await
1212 }
1213
1214 fn model(&self) -> &str {
1215 &self.model
1216 }
1217
1218 fn provider(&self) -> &'static str {
1219 "anthropic"
1220 }
1221
1222 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
1223 self.thinking.as_ref()
1224 }
1225
1226 fn default_max_tokens(&self) -> u32 {
1227 let model_max = self
1228 .capabilities()
1229 .and_then(|caps| caps.max_output_tokens)
1230 .or_else(|| {
1231 crate::model_capabilities::default_max_output_tokens(self.provider(), self.model())
1232 })
1233 .unwrap_or(4096);
1234 model_max.clamp(4096, DEFAULT_SAFE_MAX_OUTPUT_TOKENS)
1235 }
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240 use super::*;
1241
1242 const ANTHROPIC_MODELS_FIXTURE: &str = r#"{
1243 "data": [
1244 {"type": "model", "id": "claude-opus-4-8", "display_name": "Claude Opus 4.8"},
1245 {"type": "model", "id": "claude-sonnet-4-5", "display_name": "Claude Sonnet 4.5"}
1246 ],
1247 "has_more": false
1248 }"#;
1249
1250 #[test]
1251 fn parse_models_page_reads_id_and_display_name() -> anyhow::Result<()> {
1252 let page = parse_models_page(ANTHROPIC_MODELS_FIXTURE)?;
1253 assert_eq!(page.models.len(), 2);
1254 assert_eq!(page.models[0].id, "claude-opus-4-8");
1255 assert_eq!(
1256 page.models[0].display_name.as_deref(),
1257 Some("Claude Opus 4.8")
1258 );
1259 assert_eq!(page.models[0].context_window, None);
1261 assert_eq!(page.models[0].max_output_tokens, None);
1262 assert!(!page.has_more);
1264 assert_eq!(page.last_id, None);
1265 Ok(())
1266 }
1267
1268 #[tokio::test]
1269 async fn list_models_follows_pagination_across_pages() -> anyhow::Result<()> {
1270 use wiremock::matchers::{method, path, query_param, query_param_is_missing};
1271 use wiremock::{Mock, MockServer, ResponseTemplate};
1272
1273 let server = MockServer::start().await;
1274
1275 Mock::given(method("GET"))
1278 .and(path("/v1/models"))
1279 .and(query_param_is_missing("after_id"))
1280 .respond_with(ResponseTemplate::new(200).set_body_string(
1281 r#"{
1282 "data": [
1283 {"type": "model", "id": "claude-opus-4-8", "display_name": "Opus"},
1284 {"type": "model", "id": "claude-sonnet-4-5", "display_name": "Sonnet"}
1285 ],
1286 "has_more": true,
1287 "last_id": "claude-sonnet-4-5"
1288 }"#,
1289 ))
1290 .mount(&server)
1291 .await;
1292
1293 Mock::given(method("GET"))
1295 .and(path("/v1/models"))
1296 .and(query_param("after_id", "claude-sonnet-4-5"))
1297 .respond_with(ResponseTemplate::new(200).set_body_string(
1298 r#"{
1299 "data": [
1300 {"type": "model", "id": "claude-haiku-4-5", "display_name": "Haiku"}
1301 ],
1302 "has_more": false,
1303 "last_id": "claude-haiku-4-5"
1304 }"#,
1305 ))
1306 .mount(&server)
1307 .await;
1308
1309 let provider = AnthropicProvider::new("test-key-not-a-secret", "claude-test")
1310 .with_base_url(server.uri());
1311 let models = provider.list_models().await?;
1312
1313 let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect();
1315 assert_eq!(
1316 ids,
1317 vec!["claude-opus-4-8", "claude-sonnet-4-5", "claude-haiku-4-5"]
1318 );
1319 Ok(())
1320 }
1321
1322 #[test]
1327 fn test_new_creates_provider_with_custom_model() {
1328 let provider = AnthropicProvider::new("test-api-key", "custom-model");
1329
1330 assert_eq!(provider.model(), "custom-model");
1331 assert_eq!(provider.provider(), "anthropic");
1332 }
1333
1334 #[test]
1335 fn test_haiku_factory_creates_haiku_provider() {
1336 let provider = AnthropicProvider::haiku("test-api-key".to_string());
1337
1338 assert_eq!(provider.model(), MODEL_HAIKU_45);
1339 assert_eq!(provider.provider(), "anthropic");
1340 }
1341
1342 #[test]
1343 fn test_only_anthropic_46_models_accept_adaptive_thinking() {
1344 let sonnet_46 = AnthropicProvider::sonnet_46("test-api-key".to_string());
1345 assert!(
1346 sonnet_46
1347 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1348 .is_ok()
1349 );
1350
1351 let sonnet_45 = AnthropicProvider::sonnet_45("test-api-key".to_string());
1352 let error = sonnet_45
1353 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1354 .unwrap_err();
1355 assert!(
1356 error
1357 .to_string()
1358 .contains("adaptive thinking is not supported")
1359 );
1360 }
1361
1362 #[test]
1363 fn test_anthropic_46_models_reject_budgeted_thinking() {
1364 let sonnet_46 = AnthropicProvider::sonnet_46("test-api-key".to_string());
1365 let error = sonnet_46
1366 .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1367 .unwrap_err();
1368 assert!(error.to_string().contains("ThinkingConfig::adaptive()"));
1369 }
1370
1371 #[test]
1372 fn test_opus_47_rejects_budgeted_thinking() {
1373 let opus_47 = AnthropicProvider::opus_47("test-api-key".to_string());
1378 let error = opus_47
1379 .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1380 .unwrap_err();
1381 assert!(
1382 error.to_string().contains("ThinkingConfig::adaptive()"),
1383 "expected migration hint, got: {error}"
1384 );
1385 }
1386
1387 #[test]
1388 fn test_opus_47_accepts_adaptive_thinking() {
1389 let opus_47 = AnthropicProvider::opus_47("test-api-key".to_string());
1390 assert!(
1391 opus_47
1392 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1393 .is_ok()
1394 );
1395 assert!(
1396 opus_47
1397 .validate_thinking_config(Some(&ThinkingConfig::adaptive_with_effort(
1398 agent_sdk_foundation::llm::Effort::High
1399 )))
1400 .is_ok()
1401 );
1402 }
1403
1404 #[test]
1405 fn test_opus_47_factory_creates_opus_47_provider() {
1406 let provider = AnthropicProvider::opus_47("test-api-key".to_string());
1407 assert_eq!(provider.model(), MODEL_OPUS_47);
1408 assert_eq!(provider.provider(), "anthropic");
1409 }
1410
1411 #[test]
1412 fn test_opus_48_rejects_budgeted_thinking() {
1413 let opus_48 = AnthropicProvider::opus_48("test-api-key".to_string());
1418 let error = opus_48
1419 .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1420 .unwrap_err();
1421 assert!(
1422 error.to_string().contains("ThinkingConfig::adaptive()"),
1423 "expected migration hint, got: {error}"
1424 );
1425 }
1426
1427 #[test]
1428 fn test_opus_48_accepts_adaptive_thinking() {
1429 let opus_48 = AnthropicProvider::opus_48("test-api-key".to_string());
1430 assert!(
1431 opus_48
1432 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1433 .is_ok()
1434 );
1435 assert!(
1436 opus_48
1437 .validate_thinking_config(Some(&ThinkingConfig::adaptive_with_effort(
1438 agent_sdk_foundation::llm::Effort::High
1439 )))
1440 .is_ok()
1441 );
1442 }
1443
1444 #[test]
1445 fn test_opus_48_factory_creates_opus_48_provider() {
1446 let provider = AnthropicProvider::opus_48("test-api-key".to_string());
1447 assert_eq!(provider.model(), MODEL_OPUS_48);
1448 assert_eq!(provider.provider(), "anthropic");
1449 }
1450
1451 #[test]
1452 fn test_sonnet_5_rejects_budgeted_thinking() {
1453 let sonnet_5 = AnthropicProvider::sonnet_5("test-api-key".to_string());
1456 let error = sonnet_5
1457 .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1458 .unwrap_err();
1459 assert!(
1460 error.to_string().contains("ThinkingConfig::adaptive()"),
1461 "expected migration hint, got: {error}"
1462 );
1463 }
1464
1465 #[test]
1466 fn test_sonnet_5_accepts_adaptive_thinking() {
1467 let sonnet_5 = AnthropicProvider::sonnet_5("test-api-key".to_string());
1468 assert!(
1469 sonnet_5
1470 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1471 .is_ok()
1472 );
1473 assert!(
1474 sonnet_5
1475 .validate_thinking_config(Some(&ThinkingConfig::adaptive_with_effort(
1476 agent_sdk_foundation::llm::Effort::High
1477 )))
1478 .is_ok()
1479 );
1480 }
1481
1482 #[test]
1483 fn test_fable_5_rejects_budgeted_thinking() {
1484 let fable = AnthropicProvider::fable("test-api-key".to_string());
1488 let error = fable
1489 .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1490 .unwrap_err();
1491 assert!(
1492 error.to_string().contains("ThinkingConfig::adaptive()"),
1493 "expected migration hint, got: {error}"
1494 );
1495 }
1496
1497 #[test]
1498 fn test_fable_5_accepts_adaptive_thinking() {
1499 let fable = AnthropicProvider::fable("test-api-key".to_string());
1500 assert!(
1501 fable
1502 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1503 .is_ok()
1504 );
1505 assert!(
1506 fable
1507 .validate_thinking_config(Some(&ThinkingConfig::adaptive_with_effort(
1508 agent_sdk_foundation::llm::Effort::High
1509 )))
1510 .is_ok()
1511 );
1512 }
1513
1514 #[test]
1515 fn test_fable_factory_creates_fable_5_provider() {
1516 let provider = AnthropicProvider::fable("test-api-key".to_string());
1517 assert_eq!(provider.model(), MODEL_FABLE_5);
1518 assert_eq!(provider.provider(), "anthropic");
1519 }
1520
1521 #[test]
1522 fn test_sonnet_factory_creates_sonnet_provider() {
1523 let provider = AnthropicProvider::sonnet("test-api-key".to_string());
1524
1525 assert_eq!(provider.model(), MODEL_SONNET_46);
1526 assert_eq!(provider.provider(), "anthropic");
1527 }
1528
1529 #[test]
1530 fn test_sonnet_45_factory_creates_sonnet_provider() {
1531 let provider = AnthropicProvider::sonnet_45("test-api-key".to_string());
1532
1533 assert_eq!(provider.model(), MODEL_SONNET_45);
1534 assert_eq!(provider.provider(), "anthropic");
1535 }
1536
1537 #[test]
1538 fn test_sonnet_46_factory_creates_sonnet_provider() {
1539 let provider = AnthropicProvider::sonnet_46("test-api-key".to_string());
1540
1541 assert_eq!(provider.model(), MODEL_SONNET_46);
1542 assert_eq!(provider.provider(), "anthropic");
1543 }
1544
1545 #[test]
1546 fn test_opus_factory_creates_opus_provider() {
1547 let provider = AnthropicProvider::opus("test-api-key".to_string());
1548
1549 assert_eq!(provider.model(), MODEL_OPUS_46);
1550 assert_eq!(provider.provider(), "anthropic");
1551 }
1552
1553 #[test]
1558 fn test_model_constants_have_expected_values() {
1559 assert!(MODEL_HAIKU_35.contains("haiku"));
1560 assert!(MODEL_SONNET_35.contains("sonnet"));
1561 assert!(MODEL_SONNET_4.contains("sonnet"));
1562 assert!(MODEL_SONNET_46.contains("sonnet"));
1563 assert!(MODEL_OPUS_4.contains("opus"));
1564 }
1565
1566 #[test]
1571 fn test_provider_is_cloneable() {
1572 let provider = AnthropicProvider::new("test-api-key", "test-model");
1573 let cloned = provider.clone();
1574
1575 assert_eq!(provider.model(), cloned.model());
1576 assert_eq!(provider.provider(), cloned.provider());
1577 }
1578
1579 fn tool(name: &str) -> agent_sdk_foundation::llm::Tool {
1584 agent_sdk_foundation::llm::Tool {
1585 name: name.to_string(),
1586 description: "desc".to_string(),
1587 input_schema: serde_json::json!({ "type": "object" }),
1588 display_name: name.to_string(),
1589 tier: agent_sdk_foundation::ToolTier::Observe,
1590 }
1591 }
1592
1593 fn request_with_tools(tools: Vec<agent_sdk_foundation::llm::Tool>) -> ChatRequest {
1594 ChatRequest {
1595 system: String::new(),
1596 messages: vec![agent_sdk_foundation::llm::Message::user("hi")],
1597 tools: Some(tools),
1598 max_tokens: 1024,
1599 max_tokens_explicit: true,
1600 session_id: None,
1601 cached_content: None,
1602 thinking: None,
1603 tool_choice: None,
1604 response_format: None,
1605 cache: None,
1606 }
1607 }
1608
1609 #[test]
1610 fn test_oauth_tool_name_collision_detects_case_variants() {
1611 let tools = vec![tool("task"), tool("Task")];
1612 let collision = oauth_tool_name_collision(Some(&tools));
1613 assert!(collision.is_some());
1614 }
1615
1616 #[test]
1617 fn test_oauth_tool_name_collision_allows_distinct_names() {
1618 let tools = vec![tool("read"), tool("write"), tool("Read_File")];
1619 assert!(oauth_tool_name_collision(Some(&tools)).is_none());
1620 assert!(oauth_tool_name_collision(None).is_none());
1621 }
1622
1623 #[tokio::test]
1624 async fn test_oauth_chat_rejects_case_colliding_tools() -> anyhow::Result<()> {
1625 let provider = AnthropicProvider::new("sk-ant-oat-test", MODEL_SONNET_45);
1627 assert!(provider.is_oauth());
1628 let request = request_with_tools(vec![tool("task"), tool("Task")]);
1629 let outcome = provider.chat(request).await?;
1630 match outcome {
1631 ChatOutcome::InvalidRequest(msg) => {
1632 assert!(msg.contains("collide case-insensitively"), "got: {msg}");
1633 }
1634 other => panic!("expected InvalidRequest, got {other:?}"),
1635 }
1636 Ok(())
1637 }
1638
1639 #[tokio::test]
1640 async fn test_api_key_chat_does_not_apply_oauth_collision_gate() -> anyhow::Result<()> {
1641 let provider = AnthropicProvider::new("sk-ant-api-test", MODEL_SONNET_45);
1646 assert!(!provider.is_oauth());
1647 let tools = vec![tool("task"), tool("Task")];
1648 assert!(oauth_tool_name_collision(Some(&tools)).is_some());
1650 Ok(())
1651 }
1652
1653 fn apply_auth_beta_header(provider: &AnthropicProvider) -> anyhow::Result<Option<String>> {
1658 let builder = reqwest::Client::new().post("http://localhost/v1/messages");
1659 let request = provider.apply_auth(builder).build()?;
1660 Ok(request
1661 .headers()
1662 .get("anthropic-beta")
1663 .and_then(|value| value.to_str().ok())
1664 .map(str::to_owned))
1665 }
1666
1667 #[test]
1668 fn api_key_auth_sends_interleaved_beta_for_budget_thinking_models() -> anyhow::Result<()> {
1669 for provider in [
1672 AnthropicProvider::sonnet_45("test-key-not-a-secret"),
1673 AnthropicProvider::haiku("test-key-not-a-secret"),
1674 ] {
1675 assert!(!provider.is_oauth());
1676 assert_eq!(
1677 apply_auth_beta_header(&provider)?.as_deref(),
1678 Some("interleaved-thinking-2025-05-14"),
1679 "expected interleaved beta for {}",
1680 provider.model()
1681 );
1682 }
1683 Ok(())
1684 }
1685
1686 #[test]
1687 fn api_key_auth_omits_interleaved_beta_for_adaptive_models() -> anyhow::Result<()> {
1688 for provider in [
1691 AnthropicProvider::opus_48("test-key-not-a-secret"),
1692 AnthropicProvider::sonnet_5("test-key-not-a-secret"),
1693 AnthropicProvider::fable("test-key-not-a-secret"),
1694 ] {
1695 assert!(!provider.is_oauth());
1696 assert_eq!(
1697 apply_auth_beta_header(&provider)?,
1698 None,
1699 "expected no beta header for adaptive model {}",
1700 provider.model()
1701 );
1702 }
1703 Ok(())
1704 }
1705
1706 async fn captured_request_body(
1715 provider: &AnthropicProvider,
1716 request: ChatRequest,
1717 server: &wiremock::MockServer,
1718 ) -> serde_json::Value {
1719 use wiremock::matchers::{method, path};
1720 use wiremock::{Mock, ResponseTemplate};
1721
1722 Mock::given(method("POST"))
1723 .and(path("/v1/messages"))
1724 .respond_with(ResponseTemplate::new(200).set_body_string("{}"))
1725 .mount(server)
1726 .await;
1727
1728 let _ = provider.chat(request).await;
1729
1730 let received = server
1731 .received_requests()
1732 .await
1733 .expect("mock server records requests");
1734 assert_eq!(received.len(), 1, "expected exactly one request");
1735 serde_json::from_slice(&received[0].body).expect("request body is JSON")
1736 }
1737
1738 #[tokio::test]
1739 async fn forced_tool_drops_configured_thinking_on_the_wire() {
1740 let server = wiremock::MockServer::start().await;
1747 let provider = AnthropicProvider::sonnet_45("sk-ant-api-test")
1748 .with_thinking(ThinkingConfig::new(10_000))
1749 .with_base_url(server.uri());
1750
1751 let mut request = request_with_tools(vec![tool("respond")]);
1752 request.tool_choice = Some(agent_sdk_foundation::llm::ToolChoice::Tool(
1753 "respond".to_owned(),
1754 ));
1755
1756 let body = captured_request_body(&provider, request, &server).await;
1757
1758 assert!(
1759 body.get("thinking").is_none(),
1760 "thinking must be absent when a tool is forced, got: {body}"
1761 );
1762 assert_eq!(
1763 body["tool_choice"]["type"], "tool",
1764 "the forced tool_choice must survive, got: {body}"
1765 );
1766 }
1767
1768 #[tokio::test]
1769 async fn configured_thinking_survives_without_forced_tool() {
1770 let server = wiremock::MockServer::start().await;
1775 let provider = AnthropicProvider::sonnet_45("sk-ant-api-test")
1776 .with_thinking(ThinkingConfig::new(10_000))
1777 .with_base_url(server.uri());
1778
1779 let mut request = request_with_tools(vec![tool("read")]);
1780 request.tool_choice = Some(agent_sdk_foundation::llm::ToolChoice::Auto);
1781
1782 let body = captured_request_body(&provider, request, &server).await;
1783
1784 assert_eq!(
1785 body["thinking"]["type"], "enabled",
1786 "configured thinking must survive when no tool is forced, got: {body}"
1787 );
1788 }
1789
1790 #[tokio::test]
1795 async fn streaming_headers_stall_yields_connection_lost_error() {
1796 use wiremock::matchers::{method, path};
1797 use wiremock::{Mock, MockServer, ResponseTemplate};
1798
1799 let server = MockServer::start().await;
1800 Mock::given(method("POST"))
1801 .and(path("/v1/messages"))
1802 .respond_with(ResponseTemplate::new(200).set_delay(std::time::Duration::from_secs(30)))
1803 .mount(&server)
1804 .await;
1805
1806 let provider = AnthropicProvider::sonnet_45("sk-ant-api-test")
1807 .with_base_url(server.uri())
1808 .with_stream_stall_timeouts(
1809 std::time::Duration::from_millis(100),
1810 std::time::Duration::from_secs(5),
1811 );
1812
1813 let items: Vec<_> = provider
1814 .chat_stream(request_with_tools(vec![]))
1815 .collect()
1816 .await;
1817
1818 assert_eq!(items.len(), 1, "a stalled send yields exactly one item");
1819 let Some(Ok(StreamDelta::Error { message, kind })) = items.first() else {
1820 panic!("headers stall must surface as a classified error: {items:?}")
1821 };
1822 assert_eq!(*kind, StreamErrorKind::ConnectionLost);
1823 assert!(
1824 message.contains("timed out awaiting response headers"),
1825 "message must name the headers stall: {message}"
1826 );
1827 }
1828
1829 #[tokio::test]
1834 async fn sse_byte_stall_yields_connection_lost_error() {
1835 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1836
1837 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1838 .await
1839 .expect("bind test listener");
1840 let addr = listener.local_addr().expect("listener addr");
1841 let server = tokio::spawn(async move {
1842 let (mut socket, _) = listener.accept().await.expect("accept");
1843 let mut buf = [0u8; 4096];
1845 let _ = socket.read(&mut buf).await;
1846 let headers = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n";
1847 socket.write_all(headers.as_bytes()).await.expect("headers");
1848 let ping = "event: ping\ndata: {\"type\": \"ping\"}\n\n";
1851 let chunk = format!("{:x}\r\n{ping}\r\n", ping.len());
1852 socket
1853 .write_all(chunk.as_bytes())
1854 .await
1855 .expect("ping chunk");
1856 socket.flush().await.expect("flush");
1857 std::future::pending::<()>().await;
1858 });
1859
1860 let provider = AnthropicProvider::sonnet_45("sk-ant-api-test")
1861 .with_base_url(format!("http://{addr}"))
1862 .with_stream_stall_timeouts(
1863 std::time::Duration::from_secs(5),
1864 std::time::Duration::from_millis(200),
1865 );
1866
1867 let items: Vec<_> = provider
1868 .chat_stream(request_with_tools(vec![]))
1869 .collect()
1870 .await;
1871
1872 let Some(Ok(StreamDelta::Error { message, kind })) = items.last() else {
1873 panic!("byte stall must surface as a classified error: {items:?}")
1874 };
1875 assert_eq!(*kind, StreamErrorKind::ConnectionLost);
1876 assert!(
1877 message.contains("no bytes for"),
1878 "message must name the byte stall: {message}"
1879 );
1880 server.abort();
1881 }
1882}