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