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