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) => {
213 if crate::llm::http::is_retryable_http_failure(&e) {
214 AttemptOutcome::Retryable {
215 status: reqwest::StatusCode::SERVICE_UNAVAILABLE,
216 body: format!("network error: {e}"),
217 retry_after: None,
218 }
219 } else {
220 AttemptOutcome::Fatal(e)
221 }
222 }
223 }
224 }
225 })
226 .await?;
227
228 let parsed: AnthropicResponse =
229 serde_json::from_str(&response).context("Failed to parse Anthropic response")?;
230
231 tracing::debug!("Anthropic response: {:?}", parsed);
232
233 let content: Vec<ContentBlock> = parsed
234 .content
235 .into_iter()
236 .map(|block| match block {
237 AnthropicContentBlock::Text { text } => ContentBlock::Text { text },
238 AnthropicContentBlock::ToolUse { id, name, input } => {
239 ContentBlock::ToolUse { id, name, input }
240 }
241 })
242 .collect();
243
244 let llm_response = LlmResponse {
245 message: Message {
246 role: "assistant".to_string(),
247 content,
248 reasoning_content: None,
249 },
250 usage: TokenUsage {
251 prompt_tokens: parsed.usage.input_tokens,
252 completion_tokens: parsed.usage.output_tokens,
253 total_tokens: parsed.usage.input_tokens + parsed.usage.output_tokens,
254 cache_read_tokens: parsed.usage.cache_read_input_tokens,
255 cache_write_tokens: parsed.usage.cache_creation_input_tokens,
256 },
257 stop_reason: Some(parsed.stop_reason),
258 token_logprobs: Vec::new(),
259 meta: Some(LlmResponseMeta {
260 provider: Some(self.provider_name.clone()),
261 request_model: Some(self.model.clone()),
262 request_url: Some(url.clone()),
263 response_id: parsed.id,
264 response_model: parsed.model,
265 response_object: parsed.response_type,
266 first_token_ms: None,
267 duration_ms: Some(request_started_at.elapsed().as_millis() as u64),
268 }),
269 };
270
271 crate::telemetry::record_llm_usage(
272 llm_response.usage.prompt_tokens,
273 llm_response.usage.completion_tokens,
274 llm_response.usage.total_tokens,
275 llm_response.stop_reason.as_deref(),
276 );
277
278 Ok(llm_response)
279 }
280 }
281}
282
283#[async_trait]
284impl LlmClient for AnthropicClient {
285 async fn complete(
286 &self,
287 messages: &[Message],
288 system: Option<&str>,
289 tools: &[ToolDefinition],
290 ) -> Result<LlmResponse> {
291 self.send_request(self.build_request(messages, system, tools))
292 .await
293 }
294
295 async fn complete_structured(
296 &self,
297 messages: &[Message],
298 system: Option<&str>,
299 tools: &[ToolDefinition],
300 directive: &structured::StructuredDirective,
301 ) -> Result<LlmResponse> {
302 let mut request_body = self.build_request(messages, system, tools);
303 Self::apply_directive(&mut request_body, directive);
304 self.send_request(request_body).await
305 }
306
307 fn native_structured_support(&self) -> structured::NativeStructuredSupport {
308 structured::NativeStructuredSupport::ForcedTool
309 }
310
311 async fn complete_streaming(
312 &self,
313 messages: &[Message],
314 system: Option<&str>,
315 tools: &[ToolDefinition],
316 cancel_token: CancellationToken,
317 ) -> Result<mpsc::Receiver<StreamEvent>> {
318 self.send_streaming(self.build_request(messages, system, tools), cancel_token)
319 .await
320 }
321
322 async fn complete_streaming_structured(
323 &self,
324 messages: &[Message],
325 system: Option<&str>,
326 tools: &[ToolDefinition],
327 directive: &structured::StructuredDirective,
328 cancel_token: CancellationToken,
329 ) -> Result<mpsc::Receiver<StreamEvent>> {
330 let mut request_body = self.build_request(messages, system, tools);
331 Self::apply_directive(&mut request_body, directive);
332 self.send_streaming(request_body, cancel_token).await
333 }
334}
335
336impl AnthropicClient {
337 async fn send_streaming(
339 &self,
340 mut request_body: serde_json::Value,
341 cancel_token: CancellationToken,
342 ) -> Result<mpsc::Receiver<StreamEvent>> {
343 {
344 let request_started_at = Instant::now();
345 request_body["stream"] = serde_json::json!(true);
346
347 let url = format!("{}/v1/messages", self.base_url);
348
349 let headers = vec![
350 ("x-api-key", self.api_key.expose()),
351 ("anthropic-version", "2023-06-01"),
352 ("anthropic-beta", "prompt-caching-2024-07-31"),
353 ];
354
355 let streaming_resp = crate::retry::with_retry(&self.retry_config, |_attempt| {
356 let http = &self.http;
357 let url = &url;
358 let headers = headers.clone();
359 let request_body = &request_body;
360 let cancel_token = cancel_token.clone();
361 async move {
362 let resp = tokio::select! {
363 _ = cancel_token.cancelled() => {
364 return AttemptOutcome::Fatal(anyhow::Error::new(
365 crate::llm::HttpClientError::cancelled(
366 "Anthropic streaming HTTP request",
367 ),
368 ));
369 }
370 result = http.post_streaming(url, headers, request_body, cancel_token.clone()) => {
371 match result {
372 Ok(r) => r,
373 Err(e) => {
374 return if crate::llm::http::is_retryable_http_failure(&e) {
375 AttemptOutcome::Retryable {
376 status: reqwest::StatusCode::SERVICE_UNAVAILABLE,
377 body: format!("network error: {e}"),
378 retry_after: None,
379 }
380 } else {
381 AttemptOutcome::Fatal(e.context("HTTP request failed"))
382 };
383 }
384 }
385 }
386 };
387 let status = reqwest::StatusCode::from_u16(resp.status)
388 .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
389 if status.is_success() {
390 AttemptOutcome::Success(resp)
391 } else {
392 let retry_after = resp
393 .retry_after
394 .as_deref()
395 .and_then(|v| RetryConfig::parse_retry_after(Some(v)));
396 if self.retry_config.is_retryable_status(status) {
397 AttemptOutcome::Retryable {
398 status,
399 body: resp.error_body,
400 retry_after,
401 }
402 } else {
403 AttemptOutcome::Fatal(anyhow::anyhow!(
404 "Anthropic API error at {} ({}): {}",
405 url,
406 status,
407 resp.error_body
408 ))
409 }
410 }
411 }
412 })
413 .await?;
414
415 let (tx, rx) = mpsc::channel(100);
416
417 let mut stream = streaming_resp.byte_stream;
418 let provider_name = self.provider_name.clone();
419 let request_model = self.model.clone();
420 let request_url = url.clone();
421 let stream_cancellation = cancel_token.clone();
422 tokio::spawn(async move {
423 let mut buffer = String::new();
424 let mut utf8_decoder = crate::sse::Utf8StreamDecoder::default();
425 let mut content_blocks: Vec<ContentBlock> = Vec::new();
426 let mut text_content = String::new();
427 let mut current_tool_id = String::new();
428 let mut current_tool_name = String::new();
429 let mut current_tool_input = String::new();
430 let mut usage = TokenUsage::default();
431 let mut stop_reason = None;
432 let mut response_id = None;
433 let mut response_model = None;
434 let mut response_object = Some("message".to_string());
435 let mut first_token_ms = None;
436
437 loop {
438 let chunk_result = tokio::select! {
439 biased;
440 _ = stream_cancellation.cancelled() => break,
441 _ = tx.closed() => break,
442 chunk = stream.next() => match chunk {
443 Some(chunk) => chunk,
444 None => break,
445 },
446 };
447 let chunk = match chunk_result {
448 Ok(c) => c,
449 Err(e) => {
450 tracing::error!("Stream error: {}", e);
451 break;
452 }
453 };
454
455 if let Err(error) = utf8_decoder.push_to(&chunk, &mut buffer) {
456 tracing::error!(%error, "Anthropic stream returned invalid UTF-8");
457 break;
458 }
459
460 while let Some(event_end) = buffer.find("\n\n") {
461 let event_data: String = buffer.drain(..event_end).collect();
462 buffer.drain(..2);
463
464 for line in event_data.lines() {
465 if let Some(data) = crate::sse::data_field_value(line) {
466 if data == "[DONE]" {
467 continue;
468 }
469
470 if let Ok(event) =
471 serde_json::from_str::<AnthropicStreamEvent>(data)
472 {
473 match event {
474 AnthropicStreamEvent::ContentBlockStart {
475 index: _,
476 content_block,
477 } => match content_block {
478 AnthropicContentBlock::Text { .. } => {}
479 AnthropicContentBlock::ToolUse { id, name, input } => {
480 if !text_content.is_empty() {
481 content_blocks.push(ContentBlock::Text {
482 text: std::mem::take(&mut text_content),
483 });
484 }
485 current_tool_id = id.clone();
486 current_tool_name = name.clone();
487 current_tool_input =
488 Self::initial_tool_input_json(&input)
489 .unwrap_or_default();
490 let _ = tx
491 .send(StreamEvent::ToolUseStart { id, name })
492 .await;
493 if !current_tool_input.is_empty() {
494 if first_token_ms.is_none() {
495 first_token_ms = Some(
496 request_started_at.elapsed().as_millis()
497 as u64,
498 );
499 }
500 let _ = tx
501 .send(StreamEvent::ToolUseInputDelta {
502 id: Some(current_tool_id.clone()),
503 delta: current_tool_input.clone(),
504 })
505 .await;
506 }
507 }
508 },
509 AnthropicStreamEvent::ContentBlockDelta {
510 index: _,
511 delta,
512 } => match delta {
513 AnthropicDelta::TextDelta { text } => {
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 text_content.push_str(&text);
521 let _ = tx.send(StreamEvent::TextDelta(text)).await;
522 }
523 AnthropicDelta::InputJsonDelta { partial_json } => {
524 if first_token_ms.is_none() {
525 first_token_ms = Some(
526 request_started_at.elapsed().as_millis()
527 as u64,
528 );
529 }
530 current_tool_input.push_str(&partial_json);
531 let _ = tx
532 .send(StreamEvent::ToolUseInputDelta {
533 id: Some(current_tool_id.clone()),
534 delta: partial_json,
535 })
536 .await;
537 }
538 },
539 AnthropicStreamEvent::ContentBlockStop { index: _ }
540 if !current_tool_id.is_empty() =>
541 {
542 let input: serde_json::Value = if current_tool_input
543 .trim()
544 .is_empty()
545 {
546 serde_json::Value::Object(Default::default())
547 } else {
548 serde_json::from_str(¤t_tool_input)
549 .unwrap_or_else(|e| {
550 tracing::warn!(
551 "Failed to parse tool input JSON for tool '{}': {}",
552 current_tool_name, e
553 );
554 serde_json::json!({
555 "__parse_error": format!(
556 "Malformed tool arguments: {}. Raw input: {}",
557 e, ¤t_tool_input
558 )
559 })
560 })
561 };
562 content_blocks.push(ContentBlock::ToolUse {
563 id: current_tool_id.clone(),
564 name: current_tool_name.clone(),
565 input,
566 });
567 current_tool_id.clear();
568 current_tool_name.clear();
569 current_tool_input.clear();
570 }
571 AnthropicStreamEvent::MessageStart { message } => {
572 response_id = message.id;
573 response_model = message.model;
574 response_object = message.message_type;
575 usage.prompt_tokens = message.usage.input_tokens;
576 }
577 AnthropicStreamEvent::MessageDelta {
578 delta,
579 usage: msg_usage,
580 } => {
581 stop_reason = Some(delta.stop_reason);
582 usage.completion_tokens = msg_usage.output_tokens;
583 usage.total_tokens =
584 usage.prompt_tokens + usage.completion_tokens;
585 }
586 AnthropicStreamEvent::MessageStop => {
587 if !text_content.is_empty() {
588 content_blocks.push(ContentBlock::Text {
589 text: std::mem::take(&mut text_content),
590 });
591 }
592 crate::telemetry::record_llm_usage(
593 usage.prompt_tokens,
594 usage.completion_tokens,
595 usage.total_tokens,
596 stop_reason.as_deref(),
597 );
598
599 let response = LlmResponse {
600 message: Message {
601 role: "assistant".to_string(),
602 content: std::mem::take(&mut content_blocks),
603 reasoning_content: None,
604 },
605 usage: usage.clone(),
606 stop_reason: stop_reason.clone(),
607 token_logprobs: Vec::new(),
608 meta: Some(LlmResponseMeta {
609 provider: Some(provider_name.clone()),
610 request_model: Some(request_model.clone()),
611 request_url: Some(request_url.clone()),
612 response_id: response_id.clone(),
613 response_model: response_model.clone(),
614 response_object: response_object.clone(),
615 first_token_ms,
616 duration_ms: Some(
617 request_started_at.elapsed().as_millis()
618 as u64,
619 ),
620 }),
621 };
622 let _ = tx.send(StreamEvent::Done(response)).await;
623 }
624 _ => {}
625 }
626 }
627 }
628 }
629 }
630 }
631 if let Err(error) = utf8_decoder.finish() {
632 tracing::error!(%error, "Anthropic stream ended inside a UTF-8 code point");
633 }
634 });
635
636 Ok(rx)
637 }
638 }
639}
640
641#[derive(Debug, Deserialize)]
643pub(crate) struct AnthropicResponse {
644 #[serde(default)]
645 pub(crate) id: Option<String>,
646 #[serde(default)]
647 pub(crate) model: Option<String>,
648 #[serde(rename = "type", default)]
649 pub(crate) response_type: Option<String>,
650 pub(crate) content: Vec<AnthropicContentBlock>,
651 pub(crate) stop_reason: String,
652 pub(crate) usage: AnthropicUsage,
653}
654
655#[derive(Debug, Deserialize)]
656#[serde(tag = "type")]
657pub(crate) enum AnthropicContentBlock {
658 #[serde(rename = "text")]
659 Text { text: String },
660 #[serde(rename = "tool_use")]
661 ToolUse {
662 id: String,
663 name: String,
664 input: serde_json::Value,
665 },
666}
667
668#[derive(Debug, Deserialize)]
669pub(crate) struct AnthropicUsage {
670 pub(crate) input_tokens: usize,
671 pub(crate) output_tokens: usize,
672 pub(crate) cache_read_input_tokens: Option<usize>,
673 pub(crate) cache_creation_input_tokens: Option<usize>,
674}
675
676#[derive(Debug, Deserialize)]
677#[serde(tag = "type")]
678#[allow(dead_code)]
679pub(crate) enum AnthropicStreamEvent {
680 #[serde(rename = "message_start")]
681 MessageStart { message: AnthropicMessageStart },
682 #[serde(rename = "content_block_start")]
683 ContentBlockStart {
684 index: usize,
685 content_block: AnthropicContentBlock,
686 },
687 #[serde(rename = "content_block_delta")]
688 ContentBlockDelta { index: usize, delta: AnthropicDelta },
689 #[serde(rename = "content_block_stop")]
690 ContentBlockStop { index: usize },
691 #[serde(rename = "message_delta")]
692 MessageDelta {
693 delta: AnthropicMessageDeltaData,
694 usage: AnthropicOutputUsage,
695 },
696 #[serde(rename = "message_stop")]
697 MessageStop,
698 #[serde(rename = "ping")]
699 Ping,
700 #[serde(rename = "error")]
701 Error { error: AnthropicError },
702}
703
704#[derive(Debug, Deserialize)]
705pub(crate) struct AnthropicMessageStart {
706 #[serde(default)]
707 pub(crate) id: Option<String>,
708 #[serde(default)]
709 pub(crate) model: Option<String>,
710 #[serde(rename = "type", default)]
711 pub(crate) message_type: Option<String>,
712 pub(crate) usage: AnthropicUsage,
713}
714
715#[derive(Debug, Deserialize)]
716#[serde(tag = "type")]
717pub(crate) enum AnthropicDelta {
718 #[serde(rename = "text_delta")]
719 TextDelta { text: String },
720 #[serde(rename = "input_json_delta")]
721 InputJsonDelta { partial_json: String },
722}
723
724#[derive(Debug, Deserialize)]
725pub(crate) struct AnthropicMessageDeltaData {
726 pub(crate) stop_reason: String,
727}
728
729#[derive(Debug, Deserialize)]
730pub(crate) struct AnthropicOutputUsage {
731 pub(crate) output_tokens: usize,
732}
733
734#[derive(Debug, Deserialize)]
735#[allow(dead_code)]
736pub(crate) struct AnthropicError {
737 #[serde(rename = "type")]
738 pub(crate) error_type: String,
739 pub(crate) message: String,
740}
741
742#[cfg(test)]
747mod tests {
748 use super::*;
749 use crate::llm::types::{Message, ToolDefinition};
750
751 fn make_client() -> AnthropicClient {
752 AnthropicClient::new("test-key".to_string(), "claude-opus-4-6".to_string())
753 }
754
755 #[test]
756 fn test_build_request_basic() {
757 let client = make_client();
758 let messages = vec![Message::user("Hello")];
759 let req = client.build_request(&messages, None, &[]);
760
761 assert_eq!(req["model"], "claude-opus-4-6");
762 assert_eq!(req["max_tokens"], DEFAULT_MAX_TOKENS);
763 assert!(req["thinking"].is_null());
764 }
765
766 #[test]
767 fn test_build_request_with_thinking_budget() {
768 let client = make_client().with_thinking_budget(10_000);
769 let messages = vec![Message::user("Think carefully.")];
770 let req = client.build_request(&messages, None, &[]);
771
772 assert_eq!(req["thinking"]["type"], "enabled");
774 assert_eq!(req["thinking"]["budget_tokens"], 10_000);
775 assert_eq!(req["temperature"], 1.0_f64);
777 }
778
779 #[test]
780 fn test_build_request_thinking_overrides_temperature() {
781 let client = make_client()
783 .with_temperature(0.5)
784 .with_thinking_budget(5_000);
785 let messages = vec![Message::user("Test")];
786 let req = client.build_request(&messages, None, &[]);
787
788 assert_eq!(req["temperature"], 1.0_f64);
789 assert_eq!(req["thinking"]["budget_tokens"], 5_000);
790 }
791
792 #[test]
793 fn test_build_request_no_thinking_uses_temperature() {
794 let client = make_client().with_temperature(0.7);
795 let messages = vec![Message::user("Test")];
796 let req = client.build_request(&messages, None, &[]);
797
798 let temp = req["temperature"].as_f64().unwrap();
800 assert!((temp - 0.7).abs() < 0.01);
801 assert!(req["thinking"].is_null());
802 }
803
804 #[test]
805 fn test_build_request_with_system_prompt() {
806 let client = make_client();
807 let messages = vec![Message::user("Hello")];
808 let req = client.build_request(&messages, Some("You are helpful."), &[]);
809
810 let system = &req["system"];
811 assert!(system.is_array());
812 assert_eq!(system[0]["type"], "text");
813 assert_eq!(system[0]["text"], "You are helpful.");
814 assert!(system[0]["cache_control"].is_object());
815 }
816
817 #[test]
818 fn test_build_request_with_tools() {
819 let client = make_client();
820 let messages = vec![Message::user("Use a tool")];
821 let tools = vec![ToolDefinition {
822 name: "read_file".to_string(),
823 description: "Read a file".to_string(),
824 parameters: serde_json::json!({"type": "object", "properties": {}}),
825 }];
826 let req = client.build_request(&messages, None, &tools);
827
828 assert!(req["tools"].is_array());
829 assert_eq!(req["tools"][0]["name"], "read_file");
830 assert!(req["tools"][0]["cache_control"].is_object());
832 }
833
834 #[test]
835 fn test_build_request_thinking_budget_sets_max_tokens() {
836 let client = make_client()
838 .with_max_tokens(16_000)
839 .with_thinking_budget(8_000);
840 let messages = vec![Message::user("Test")];
841 let req = client.build_request(&messages, None, &[]);
842
843 assert_eq!(req["max_tokens"], 16_000);
844 assert_eq!(req["thinking"]["budget_tokens"], 8_000);
845 }
846
847 #[test]
848 fn test_apply_directive_forces_tool_choice() {
849 let mut req = serde_json::json!({ "model": "m", "messages": [] });
850 let directive = structured::StructuredDirective {
851 force_tool: Some("emit_person".to_string()),
852 response_format: None,
853 };
854 AnthropicClient::apply_directive(&mut req, &directive);
855 assert_eq!(req["tool_choice"]["type"], "tool");
856 assert_eq!(req["tool_choice"]["name"], "emit_person");
857 }
858
859 #[test]
860 fn test_apply_directive_ignores_response_format() {
861 let mut req = serde_json::json!({ "model": "m" });
864 AnthropicClient::apply_directive(
865 &mut req,
866 &structured::StructuredDirective {
867 force_tool: None,
868 response_format: Some(structured::ResponseFormat::JsonObject),
869 },
870 );
871 assert!(req.get("response_format").is_none());
872 assert!(req.get("tool_choice").is_none());
873 }
874
875 #[test]
876 fn test_native_structured_support_is_forced_tool() {
877 assert_eq!(
878 make_client().native_structured_support(),
879 structured::NativeStructuredSupport::ForcedTool
880 );
881 }
882}