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