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, 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
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::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    /// Execute a fully-built streaming request body (sets `stream: true`).
353    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(&current_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, &current_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// Anthropic API response types (private)
662#[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// ============================================================================
763// Tests
764// ============================================================================
765
766#[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        // thinking block must be present
793        assert_eq!(req["thinking"]["type"], "enabled");
794        assert_eq!(req["thinking"]["budget_tokens"], 10_000);
795        // temperature must be 1.0 when thinking is enabled
796        assert_eq!(req["temperature"], 1.0_f64);
797    }
798
799    #[test]
800    fn test_build_request_thinking_overrides_temperature() {
801        // Even if temperature was set, thinking forces it to 1.0
802        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        // Use approximate comparison for f64
819        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        // Last tool should have cache_control
851        assert!(req["tools"][0]["cache_control"].is_object());
852    }
853
854    #[test]
855    fn test_build_request_thinking_budget_sets_max_tokens() {
856        // max_tokens is still respected when thinking is enabled
857        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        // Anthropic has no response_format; both a response_format-only and an
883        // empty directive must be no-ops.
884        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}