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