1use super::http::{default_http_client, normalize_base_url, HttpClient};
4use super::structured;
5use super::types::*;
6use super::LlmClient;
7use crate::retry::{AttemptOutcome, RetryConfig};
8use anyhow::{Context, Result};
9use async_trait::async_trait;
10use futures::StreamExt;
11use serde::Deserialize;
12use std::sync::Arc;
13use std::time::Instant;
14use tokio::sync::mpsc;
15use tokio_util::sync::CancellationToken;
16
17pub(crate) const DEFAULT_MAX_TOKENS: usize = 8192;
19
20pub struct AnthropicClient {
22 pub(crate) provider_name: String,
23 pub(crate) api_key: SecretString,
24 pub(crate) model: String,
25 pub(crate) base_url: String,
26 pub(crate) max_tokens: usize,
27 pub(crate) temperature: Option<f32>,
28 pub(crate) thinking_budget: Option<usize>,
29 pub(crate) http: Arc<dyn HttpClient>,
30 pub(crate) retry_config: RetryConfig,
31}
32
33impl AnthropicClient {
34 pub fn new(api_key: String, model: String) -> Self {
35 Self {
36 provider_name: "anthropic".to_string(),
37 api_key: SecretString::new(api_key),
38 model,
39 base_url: "https://api.anthropic.com".to_string(),
40 max_tokens: DEFAULT_MAX_TOKENS,
41 temperature: None,
42 thinking_budget: None,
43 http: default_http_client(),
44 retry_config: RetryConfig::default(),
45 }
46 }
47
48 pub fn with_base_url(mut self, base_url: String) -> Self {
49 self.base_url = normalize_base_url(&base_url);
50 self
51 }
52
53 pub fn with_provider_name(mut self, provider_name: impl Into<String>) -> Self {
54 self.provider_name = provider_name.into();
55 self
56 }
57
58 pub fn with_max_tokens(mut self, max_tokens: usize) -> Self {
59 self.max_tokens = max_tokens;
60 self
61 }
62
63 pub fn with_temperature(mut self, temperature: f32) -> Self {
64 self.temperature = Some(temperature);
65 self
66 }
67
68 pub fn with_thinking_budget(mut self, budget: usize) -> Self {
69 self.thinking_budget = Some(budget);
70 self
71 }
72
73 pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
74 self.retry_config = retry_config;
75 self
76 }
77
78 pub fn with_http_client(mut self, http: Arc<dyn HttpClient>) -> Self {
79 self.http = http;
80 self
81 }
82
83 fn initial_tool_input_json(input: &serde_json::Value) -> Option<String> {
84 match input {
85 serde_json::Value::Object(map) if map.is_empty() => None,
86 serde_json::Value::Null => None,
87 value => serde_json::to_string(value).ok(),
88 }
89 }
90
91 pub(crate) fn build_request(
92 &self,
93 messages: &[Message],
94 system: Option<&str>,
95 tools: &[ToolDefinition],
96 ) -> serde_json::Value {
97 let mut request = serde_json::json!({
98 "model": self.model,
99 "max_tokens": self.max_tokens,
100 "messages": messages,
101 });
102
103 if let Some(sys) = system {
107 request["system"] = serde_json::json!([
108 {
109 "type": "text",
110 "text": sys,
111 "cache_control": { "type": "ephemeral" }
112 }
113 ]);
114 }
115
116 if !tools.is_empty() {
117 let mut tool_defs: Vec<serde_json::Value> = tools
118 .iter()
119 .map(|t| {
120 serde_json::json!({
121 "name": t.name,
122 "description": t.description,
123 "input_schema": t.parameters,
124 })
125 })
126 .collect();
127
128 if let Some(last) = tool_defs.last_mut() {
131 last["cache_control"] = serde_json::json!({ "type": "ephemeral" });
132 }
133
134 request["tools"] = serde_json::json!(tool_defs);
135 }
136
137 if let Some(temp) = self.temperature {
139 request["temperature"] = serde_json::json!(temp);
140 }
141
142 if let Some(budget) = self.thinking_budget {
144 request["thinking"] = serde_json::json!({
145 "type": "enabled",
146 "budget_tokens": budget
147 });
148 request["temperature"] = serde_json::json!(1.0);
150 }
151
152 request
153 }
154}
155
156impl AnthropicClient {
157 fn apply_directive(
162 request: &mut serde_json::Value,
163 directive: &structured::StructuredDirective,
164 ) {
165 if let Some(tool) = &directive.force_tool {
166 request["tool_choice"] = serde_json::json!({ "type": "tool", "name": tool });
167 }
168 }
169
170 async fn send_request(&self, request_body: serde_json::Value) -> Result<LlmResponse> {
172 {
173 let request_started_at = Instant::now();
174 let url = format!("{}/v1/messages", self.base_url);
175
176 let headers = vec![
177 ("x-api-key", self.api_key.expose()),
178 ("anthropic-version", "2023-06-01"),
179 ("anthropic-beta", "prompt-caching-2024-07-31"),
180 ];
181
182 let response = crate::retry::with_retry(&self.retry_config, |_attempt| {
183 let http = &self.http;
184 let url = &url;
185 let headers = headers.clone();
186 let request_body = &request_body;
187 async move {
188 match http
189 .post(url, headers, request_body, CancellationToken::new())
190 .await
191 {
192 Ok(resp) => {
193 let status = reqwest::StatusCode::from_u16(resp.status)
194 .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
195 if status.is_success() {
196 AttemptOutcome::Success(resp.body)
197 } else if self.retry_config.is_retryable_status(status) {
198 AttemptOutcome::Retryable {
199 status,
200 body: resp.body,
201 retry_after: None,
202 }
203 } else {
204 AttemptOutcome::Fatal(anyhow::anyhow!(
205 "Anthropic API error at {} ({}): {}",
206 url,
207 status,
208 resp.body
209 ))
210 }
211 }
212 Err(e) => AttemptOutcome::Fatal(e),
213 }
214 }
215 })
216 .await?;
217
218 let parsed: AnthropicResponse =
219 serde_json::from_str(&response).context("Failed to parse Anthropic response")?;
220
221 tracing::debug!("Anthropic response: {:?}", parsed);
222
223 let content: Vec<ContentBlock> = parsed
224 .content
225 .into_iter()
226 .map(|block| match block {
227 AnthropicContentBlock::Text { text } => ContentBlock::Text { text },
228 AnthropicContentBlock::ToolUse { id, name, input } => {
229 ContentBlock::ToolUse { id, name, input }
230 }
231 })
232 .collect();
233
234 let llm_response = LlmResponse {
235 message: Message {
236 role: "assistant".to_string(),
237 content,
238 reasoning_content: None,
239 },
240 usage: TokenUsage {
241 prompt_tokens: parsed.usage.input_tokens,
242 completion_tokens: parsed.usage.output_tokens,
243 total_tokens: parsed.usage.input_tokens + parsed.usage.output_tokens,
244 cache_read_tokens: parsed.usage.cache_read_input_tokens,
245 cache_write_tokens: parsed.usage.cache_creation_input_tokens,
246 },
247 stop_reason: Some(parsed.stop_reason),
248 token_logprobs: Vec::new(),
249 meta: Some(LlmResponseMeta {
250 provider: Some(self.provider_name.clone()),
251 request_model: Some(self.model.clone()),
252 request_url: Some(url.clone()),
253 response_id: parsed.id,
254 response_model: parsed.model,
255 response_object: parsed.response_type,
256 first_token_ms: None,
257 duration_ms: Some(request_started_at.elapsed().as_millis() as u64),
258 }),
259 };
260
261 crate::telemetry::record_llm_usage(
262 llm_response.usage.prompt_tokens,
263 llm_response.usage.completion_tokens,
264 llm_response.usage.total_tokens,
265 llm_response.stop_reason.as_deref(),
266 );
267
268 Ok(llm_response)
269 }
270 }
271}
272
273#[async_trait]
274impl LlmClient for AnthropicClient {
275 async fn complete(
276 &self,
277 messages: &[Message],
278 system: Option<&str>,
279 tools: &[ToolDefinition],
280 ) -> Result<LlmResponse> {
281 self.send_request(self.build_request(messages, system, tools))
282 .await
283 }
284
285 async fn complete_structured(
286 &self,
287 messages: &[Message],
288 system: Option<&str>,
289 tools: &[ToolDefinition],
290 directive: &structured::StructuredDirective,
291 ) -> Result<LlmResponse> {
292 let mut request_body = self.build_request(messages, system, tools);
293 Self::apply_directive(&mut request_body, directive);
294 self.send_request(request_body).await
295 }
296
297 fn native_structured_support(&self) -> structured::NativeStructuredSupport {
298 structured::NativeStructuredSupport::ForcedTool
299 }
300
301 async fn complete_streaming(
302 &self,
303 messages: &[Message],
304 system: Option<&str>,
305 tools: &[ToolDefinition],
306 cancel_token: CancellationToken,
307 ) -> Result<mpsc::Receiver<StreamEvent>> {
308 self.send_streaming(self.build_request(messages, system, tools), cancel_token)
309 .await
310 }
311
312 async fn complete_streaming_structured(
313 &self,
314 messages: &[Message],
315 system: Option<&str>,
316 tools: &[ToolDefinition],
317 directive: &structured::StructuredDirective,
318 cancel_token: CancellationToken,
319 ) -> Result<mpsc::Receiver<StreamEvent>> {
320 let mut request_body = self.build_request(messages, system, tools);
321 Self::apply_directive(&mut request_body, directive);
322 self.send_streaming(request_body, cancel_token).await
323 }
324}
325
326impl AnthropicClient {
327 async fn send_streaming(
329 &self,
330 mut request_body: serde_json::Value,
331 cancel_token: CancellationToken,
332 ) -> Result<mpsc::Receiver<StreamEvent>> {
333 {
334 let request_started_at = Instant::now();
335 request_body["stream"] = serde_json::json!(true);
336
337 let url = format!("{}/v1/messages", self.base_url);
338
339 let headers = vec![
340 ("x-api-key", self.api_key.expose()),
341 ("anthropic-version", "2023-06-01"),
342 ("anthropic-beta", "prompt-caching-2024-07-31"),
343 ];
344
345 let streaming_resp = crate::retry::with_retry(&self.retry_config, |_attempt| {
346 let http = &self.http;
347 let url = &url;
348 let headers = headers.clone();
349 let request_body = &request_body;
350 let cancel_token = cancel_token.clone();
351 async move {
352 let resp = tokio::select! {
353 _ = cancel_token.cancelled() => {
354 return AttemptOutcome::Fatal(anyhow::anyhow!("HTTP request cancelled"));
355 }
356 result = http.post_streaming(url, headers, request_body, cancel_token.clone()) => {
357 match result {
358 Ok(r) => r,
359 Err(e) => {
360 return if crate::retry::is_transient_error(&e) {
366 AttemptOutcome::Retryable {
367 status: reqwest::StatusCode::SERVICE_UNAVAILABLE,
368 body: format!("network error: {e}"),
369 retry_after: None,
370 }
371 } else {
372 AttemptOutcome::Fatal(anyhow::anyhow!(
373 "HTTP request failed: {}",
374 e
375 ))
376 };
377 }
378 }
379 }
380 };
381 let status = reqwest::StatusCode::from_u16(resp.status)
382 .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
383 if status.is_success() {
384 AttemptOutcome::Success(resp)
385 } else {
386 let retry_after = resp
387 .retry_after
388 .as_deref()
389 .and_then(|v| RetryConfig::parse_retry_after(Some(v)));
390 if self.retry_config.is_retryable_status(status) {
391 AttemptOutcome::Retryable {
392 status,
393 body: resp.error_body,
394 retry_after,
395 }
396 } else {
397 AttemptOutcome::Fatal(anyhow::anyhow!(
398 "Anthropic API error at {} ({}): {}",
399 url,
400 status,
401 resp.error_body
402 ))
403 }
404 }
405 }
406 })
407 .await?;
408
409 let (tx, rx) = mpsc::channel(100);
410
411 let mut stream = streaming_resp.byte_stream;
412 let provider_name = self.provider_name.clone();
413 let request_model = self.model.clone();
414 let request_url = url.clone();
415 let stream_cancellation = cancel_token.clone();
416 tokio::spawn(async move {
417 let mut buffer = String::new();
418 let mut content_blocks: Vec<ContentBlock> = Vec::new();
419 let mut text_content = String::new();
420 let mut current_tool_id = String::new();
421 let mut current_tool_name = String::new();
422 let mut current_tool_input = String::new();
423 let mut usage = TokenUsage::default();
424 let mut stop_reason = None;
425 let mut response_id = None;
426 let mut response_model = None;
427 let mut response_object = Some("message".to_string());
428 let mut first_token_ms = None;
429
430 loop {
431 let chunk_result = tokio::select! {
432 biased;
433 _ = stream_cancellation.cancelled() => break,
434 _ = tx.closed() => break,
435 chunk = stream.next() => match chunk {
436 Some(chunk) => chunk,
437 None => break,
438 },
439 };
440 let chunk = match chunk_result {
441 Ok(c) => c,
442 Err(e) => {
443 tracing::error!("Stream error: {}", e);
444 break;
445 }
446 };
447
448 buffer.push_str(&String::from_utf8_lossy(&chunk));
449
450 while let Some(event_end) = buffer.find("\n\n") {
451 let event_data: String = buffer.drain(..event_end).collect();
452 buffer.drain(..2);
453
454 for line in event_data.lines() {
455 if let Some(data) = crate::sse::data_field_value(line) {
456 if data == "[DONE]" {
457 continue;
458 }
459
460 if let Ok(event) =
461 serde_json::from_str::<AnthropicStreamEvent>(data)
462 {
463 match event {
464 AnthropicStreamEvent::ContentBlockStart {
465 index: _,
466 content_block,
467 } => match content_block {
468 AnthropicContentBlock::Text { .. } => {}
469 AnthropicContentBlock::ToolUse { id, name, input } => {
470 if !text_content.is_empty() {
471 content_blocks.push(ContentBlock::Text {
472 text: std::mem::take(&mut text_content),
473 });
474 }
475 current_tool_id = id.clone();
476 current_tool_name = name.clone();
477 current_tool_input =
478 Self::initial_tool_input_json(&input)
479 .unwrap_or_default();
480 let _ = tx
481 .send(StreamEvent::ToolUseStart { id, name })
482 .await;
483 if !current_tool_input.is_empty() {
484 if first_token_ms.is_none() {
485 first_token_ms = Some(
486 request_started_at.elapsed().as_millis()
487 as u64,
488 );
489 }
490 let _ = tx
491 .send(StreamEvent::ToolUseInputDelta {
492 id: Some(current_tool_id.clone()),
493 delta: current_tool_input.clone(),
494 })
495 .await;
496 }
497 }
498 },
499 AnthropicStreamEvent::ContentBlockDelta {
500 index: _,
501 delta,
502 } => match delta {
503 AnthropicDelta::TextDelta { text } => {
504 if first_token_ms.is_none() {
505 first_token_ms = Some(
506 request_started_at.elapsed().as_millis()
507 as u64,
508 );
509 }
510 text_content.push_str(&text);
511 let _ = tx.send(StreamEvent::TextDelta(text)).await;
512 }
513 AnthropicDelta::InputJsonDelta { partial_json } => {
514 if first_token_ms.is_none() {
515 first_token_ms = Some(
516 request_started_at.elapsed().as_millis()
517 as u64,
518 );
519 }
520 current_tool_input.push_str(&partial_json);
521 let _ = tx
522 .send(StreamEvent::ToolUseInputDelta {
523 id: Some(current_tool_id.clone()),
524 delta: partial_json,
525 })
526 .await;
527 }
528 },
529 AnthropicStreamEvent::ContentBlockStop { index: _ }
530 if !current_tool_id.is_empty() =>
531 {
532 let input: serde_json::Value = if current_tool_input
533 .trim()
534 .is_empty()
535 {
536 serde_json::Value::Object(Default::default())
537 } else {
538 serde_json::from_str(¤t_tool_input)
539 .unwrap_or_else(|e| {
540 tracing::warn!(
541 "Failed to parse tool input JSON for tool '{}': {}",
542 current_tool_name, e
543 );
544 serde_json::json!({
545 "__parse_error": format!(
546 "Malformed tool arguments: {}. Raw input: {}",
547 e, ¤t_tool_input
548 )
549 })
550 })
551 };
552 content_blocks.push(ContentBlock::ToolUse {
553 id: current_tool_id.clone(),
554 name: current_tool_name.clone(),
555 input,
556 });
557 current_tool_id.clear();
558 current_tool_name.clear();
559 current_tool_input.clear();
560 }
561 AnthropicStreamEvent::MessageStart { message } => {
562 response_id = message.id;
563 response_model = message.model;
564 response_object = message.message_type;
565 usage.prompt_tokens = message.usage.input_tokens;
566 }
567 AnthropicStreamEvent::MessageDelta {
568 delta,
569 usage: msg_usage,
570 } => {
571 stop_reason = Some(delta.stop_reason);
572 usage.completion_tokens = msg_usage.output_tokens;
573 usage.total_tokens =
574 usage.prompt_tokens + usage.completion_tokens;
575 }
576 AnthropicStreamEvent::MessageStop => {
577 if !text_content.is_empty() {
578 content_blocks.push(ContentBlock::Text {
579 text: std::mem::take(&mut text_content),
580 });
581 }
582 crate::telemetry::record_llm_usage(
583 usage.prompt_tokens,
584 usage.completion_tokens,
585 usage.total_tokens,
586 stop_reason.as_deref(),
587 );
588
589 let response = LlmResponse {
590 message: Message {
591 role: "assistant".to_string(),
592 content: std::mem::take(&mut content_blocks),
593 reasoning_content: None,
594 },
595 usage: usage.clone(),
596 stop_reason: stop_reason.clone(),
597 token_logprobs: Vec::new(),
598 meta: Some(LlmResponseMeta {
599 provider: Some(provider_name.clone()),
600 request_model: Some(request_model.clone()),
601 request_url: Some(request_url.clone()),
602 response_id: response_id.clone(),
603 response_model: response_model.clone(),
604 response_object: response_object.clone(),
605 first_token_ms,
606 duration_ms: Some(
607 request_started_at.elapsed().as_millis()
608 as u64,
609 ),
610 }),
611 };
612 let _ = tx.send(StreamEvent::Done(response)).await;
613 }
614 _ => {}
615 }
616 }
617 }
618 }
619 }
620 }
621 });
622
623 Ok(rx)
624 }
625 }
626}
627
628#[derive(Debug, Deserialize)]
630pub(crate) struct AnthropicResponse {
631 #[serde(default)]
632 pub(crate) id: Option<String>,
633 #[serde(default)]
634 pub(crate) model: Option<String>,
635 #[serde(rename = "type", default)]
636 pub(crate) response_type: Option<String>,
637 pub(crate) content: Vec<AnthropicContentBlock>,
638 pub(crate) stop_reason: String,
639 pub(crate) usage: AnthropicUsage,
640}
641
642#[derive(Debug, Deserialize)]
643#[serde(tag = "type")]
644pub(crate) enum AnthropicContentBlock {
645 #[serde(rename = "text")]
646 Text { text: String },
647 #[serde(rename = "tool_use")]
648 ToolUse {
649 id: String,
650 name: String,
651 input: serde_json::Value,
652 },
653}
654
655#[derive(Debug, Deserialize)]
656pub(crate) struct AnthropicUsage {
657 pub(crate) input_tokens: usize,
658 pub(crate) output_tokens: usize,
659 pub(crate) cache_read_input_tokens: Option<usize>,
660 pub(crate) cache_creation_input_tokens: Option<usize>,
661}
662
663#[derive(Debug, Deserialize)]
664#[serde(tag = "type")]
665#[allow(dead_code)]
666pub(crate) enum AnthropicStreamEvent {
667 #[serde(rename = "message_start")]
668 MessageStart { message: AnthropicMessageStart },
669 #[serde(rename = "content_block_start")]
670 ContentBlockStart {
671 index: usize,
672 content_block: AnthropicContentBlock,
673 },
674 #[serde(rename = "content_block_delta")]
675 ContentBlockDelta { index: usize, delta: AnthropicDelta },
676 #[serde(rename = "content_block_stop")]
677 ContentBlockStop { index: usize },
678 #[serde(rename = "message_delta")]
679 MessageDelta {
680 delta: AnthropicMessageDeltaData,
681 usage: AnthropicOutputUsage,
682 },
683 #[serde(rename = "message_stop")]
684 MessageStop,
685 #[serde(rename = "ping")]
686 Ping,
687 #[serde(rename = "error")]
688 Error { error: AnthropicError },
689}
690
691#[derive(Debug, Deserialize)]
692pub(crate) struct AnthropicMessageStart {
693 #[serde(default)]
694 pub(crate) id: Option<String>,
695 #[serde(default)]
696 pub(crate) model: Option<String>,
697 #[serde(rename = "type", default)]
698 pub(crate) message_type: Option<String>,
699 pub(crate) usage: AnthropicUsage,
700}
701
702#[derive(Debug, Deserialize)]
703#[serde(tag = "type")]
704pub(crate) enum AnthropicDelta {
705 #[serde(rename = "text_delta")]
706 TextDelta { text: String },
707 #[serde(rename = "input_json_delta")]
708 InputJsonDelta { partial_json: String },
709}
710
711#[derive(Debug, Deserialize)]
712pub(crate) struct AnthropicMessageDeltaData {
713 pub(crate) stop_reason: String,
714}
715
716#[derive(Debug, Deserialize)]
717pub(crate) struct AnthropicOutputUsage {
718 pub(crate) output_tokens: usize,
719}
720
721#[derive(Debug, Deserialize)]
722#[allow(dead_code)]
723pub(crate) struct AnthropicError {
724 #[serde(rename = "type")]
725 pub(crate) error_type: String,
726 pub(crate) message: String,
727}
728
729#[cfg(test)]
734mod tests {
735 use super::*;
736 use crate::llm::types::{Message, ToolDefinition};
737
738 fn make_client() -> AnthropicClient {
739 AnthropicClient::new("test-key".to_string(), "claude-opus-4-6".to_string())
740 }
741
742 #[test]
743 fn test_build_request_basic() {
744 let client = make_client();
745 let messages = vec![Message::user("Hello")];
746 let req = client.build_request(&messages, None, &[]);
747
748 assert_eq!(req["model"], "claude-opus-4-6");
749 assert_eq!(req["max_tokens"], DEFAULT_MAX_TOKENS);
750 assert!(req["thinking"].is_null());
751 }
752
753 #[test]
754 fn test_build_request_with_thinking_budget() {
755 let client = make_client().with_thinking_budget(10_000);
756 let messages = vec![Message::user("Think carefully.")];
757 let req = client.build_request(&messages, None, &[]);
758
759 assert_eq!(req["thinking"]["type"], "enabled");
761 assert_eq!(req["thinking"]["budget_tokens"], 10_000);
762 assert_eq!(req["temperature"], 1.0_f64);
764 }
765
766 #[test]
767 fn test_build_request_thinking_overrides_temperature() {
768 let client = make_client()
770 .with_temperature(0.5)
771 .with_thinking_budget(5_000);
772 let messages = vec![Message::user("Test")];
773 let req = client.build_request(&messages, None, &[]);
774
775 assert_eq!(req["temperature"], 1.0_f64);
776 assert_eq!(req["thinking"]["budget_tokens"], 5_000);
777 }
778
779 #[test]
780 fn test_build_request_no_thinking_uses_temperature() {
781 let client = make_client().with_temperature(0.7);
782 let messages = vec![Message::user("Test")];
783 let req = client.build_request(&messages, None, &[]);
784
785 let temp = req["temperature"].as_f64().unwrap();
787 assert!((temp - 0.7).abs() < 0.01);
788 assert!(req["thinking"].is_null());
789 }
790
791 #[test]
792 fn test_build_request_with_system_prompt() {
793 let client = make_client();
794 let messages = vec![Message::user("Hello")];
795 let req = client.build_request(&messages, Some("You are helpful."), &[]);
796
797 let system = &req["system"];
798 assert!(system.is_array());
799 assert_eq!(system[0]["type"], "text");
800 assert_eq!(system[0]["text"], "You are helpful.");
801 assert!(system[0]["cache_control"].is_object());
802 }
803
804 #[test]
805 fn test_build_request_with_tools() {
806 let client = make_client();
807 let messages = vec![Message::user("Use a tool")];
808 let tools = vec![ToolDefinition {
809 name: "read_file".to_string(),
810 description: "Read a file".to_string(),
811 parameters: serde_json::json!({"type": "object", "properties": {}}),
812 }];
813 let req = client.build_request(&messages, None, &tools);
814
815 assert!(req["tools"].is_array());
816 assert_eq!(req["tools"][0]["name"], "read_file");
817 assert!(req["tools"][0]["cache_control"].is_object());
819 }
820
821 #[test]
822 fn test_build_request_thinking_budget_sets_max_tokens() {
823 let client = make_client()
825 .with_max_tokens(16_000)
826 .with_thinking_budget(8_000);
827 let messages = vec![Message::user("Test")];
828 let req = client.build_request(&messages, None, &[]);
829
830 assert_eq!(req["max_tokens"], 16_000);
831 assert_eq!(req["thinking"]["budget_tokens"], 8_000);
832 }
833
834 #[test]
835 fn test_apply_directive_forces_tool_choice() {
836 let mut req = serde_json::json!({ "model": "m", "messages": [] });
837 let directive = structured::StructuredDirective {
838 force_tool: Some("emit_person".to_string()),
839 response_format: None,
840 };
841 AnthropicClient::apply_directive(&mut req, &directive);
842 assert_eq!(req["tool_choice"]["type"], "tool");
843 assert_eq!(req["tool_choice"]["name"], "emit_person");
844 }
845
846 #[test]
847 fn test_apply_directive_ignores_response_format() {
848 let mut req = serde_json::json!({ "model": "m" });
851 AnthropicClient::apply_directive(
852 &mut req,
853 &structured::StructuredDirective {
854 force_tool: None,
855 response_format: Some(structured::ResponseFormat::JsonObject),
856 },
857 );
858 assert!(req.get("response_format").is_none());
859 assert!(req.get("tool_choice").is_none());
860 }
861
862 #[test]
863 fn test_native_structured_support_is_forced_tool() {
864 assert_eq!(
865 make_client().native_structured_support(),
866 structured::NativeStructuredSupport::ForcedTool
867 );
868 }
869}