Skip to main content

a3s_code_core/llm/
anthropic.rs

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