Skip to main content

autumn_plugin_agent/
client.rs

1//! Provider clients: one trait, two HTTP implementations.
2//!
3//! [`LlmClient`] speaks chat completions with tool calls. The two
4//! implementations speak HTTP directly with `reqwest` — no heavyweight LLM
5//! SDK sits in between:
6//!
7//! * [`OpenAiCompatibleClient`] — OpenAI chat-completions protocol. Works
8//!   with OpenAI, Ollama, vLLM, LM Studio, and any other
9//!   endpoint that answers `POST /chat/completions`.
10//! * [`AnthropicClient`] — Anthropic Messages API (`POST /v1/messages`).
11//!
12//! Build the right one from config with [`client_from_config`]. Both clients
13//! redact the API key from their `Debug` output.
14
15use std::future::Future;
16use std::pin::Pin;
17use std::sync::Arc;
18use std::time::Duration;
19
20use serde::{Deserialize, Serialize};
21
22use crate::config::AgentConfig;
23use crate::error::{AgentError, ErrorKind};
24
25/// A single turn participant.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ChatRole {
29    /// Provider instructions.
30    System,
31    /// The human (or the harness).
32    User,
33    /// The model.
34    Assistant,
35    /// Tool execution results.
36    Tool,
37}
38
39/// One piece of a [`ChatMessage`].
40// `serde_json::Value` has no `Eq` impl (JSON numbers may be floats), so this
41// enum cannot be `Eq` either.
42//
43// Serialized externally tagged (`{"text": "..."}`, `{"tool_call": {..}}`) so
44// session stores can persist transcripts as JSON.
45#[allow(clippy::derive_partial_eq_without_eq)]
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum ContentPart {
49    /// Plain text.
50    Text(String),
51    /// The model wants a tool executed.
52    ToolCall {
53        /// Provider-assigned call id, echoed back with the result.
54        id: String,
55        /// Tool name.
56        name: String,
57        /// Decoded JSON arguments.
58        arguments: serde_json::Value,
59    },
60    /// The outcome of one tool execution.
61    ToolResult {
62        /// The `id` of the [`ContentPart::ToolCall`] this answers.
63        tool_call_id: String,
64        /// JSON-serialized tool output.
65        content: String,
66    },
67}
68
69/// One chat message: a role plus its content parts.
70///
71/// Serializable, so a [`SessionStore`](crate::session::SessionStore) can keep
72/// transcripts in a database column.
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct ChatMessage {
75    /// Who sent the message.
76    pub role: ChatRole,
77    /// The message payload.
78    pub content: Vec<ContentPart>,
79}
80
81impl ChatMessage {
82    /// Build a single-text message.
83    #[must_use]
84    pub fn text(role: ChatRole, text: impl Into<String>) -> Self {
85        Self {
86            role,
87            content: vec![ContentPart::Text(text.into())],
88        }
89    }
90}
91
92/// A tool the model may call, in provider-neutral form.
93// `input_schema` is a `serde_json::Value`, which has no `Eq` impl.
94#[allow(clippy::derive_partial_eq_without_eq)]
95#[derive(Debug, Clone, PartialEq)]
96pub struct ToolDefinition {
97    /// Tool name as the model calls it.
98    pub name: String,
99    /// What the tool does, shown to the model.
100    pub description: String,
101    /// JSON Schema for the tool's input object.
102    pub input_schema: serde_json::Value,
103}
104
105/// One chat-completions request.
106#[derive(Debug, Clone, PartialEq)]
107pub struct ChatRequest {
108    /// Full conversation history.
109    pub messages: Vec<ChatMessage>,
110    /// Tools the model may call this turn.
111    pub tools: Vec<ToolDefinition>,
112    /// Per-call output cap. Providers may require this (Anthropic does).
113    pub max_tokens: Option<u32>,
114    /// Sampling temperature, when the caller wants one.
115    pub temperature: Option<f32>,
116}
117
118/// Why the model stopped.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum StopReason {
121    /// The model finished its turn.
122    EndTurn,
123    /// The model emitted tool calls.
124    ToolUse,
125    /// The model hit an output cap.
126    MaxTokens,
127    /// The provider sent a reason this client does not recognise.
128    Unknown,
129}
130
131/// Token counts reported by the provider for one call.
132///
133/// `input_tokens` is the full prompt size, cached or not. The two cache
134/// fields are subsets of it: they tell you how much of the prompt the
135/// provider served from (or wrote to) its prompt cache.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
137pub struct TokenUsage {
138    /// Prompt tokens consumed, including cached tokens.
139    pub input_tokens: u32,
140    /// Completion tokens produced.
141    pub output_tokens: u32,
142    /// Prompt tokens read from the provider's prompt cache.
143    #[serde(default)]
144    pub cache_read_tokens: u32,
145    /// Prompt tokens written to the provider's prompt cache.
146    #[serde(default)]
147    pub cache_write_tokens: u32,
148}
149
150impl TokenUsage {
151    /// Usage with uncached input and output counts.
152    #[must_use]
153    pub const fn new(input_tokens: u32, output_tokens: u32) -> Self {
154        Self {
155            input_tokens,
156            output_tokens,
157            cache_read_tokens: 0,
158            cache_write_tokens: 0,
159        }
160    }
161
162    /// Add two usages without overflowing.
163    #[must_use]
164    pub const fn saturating_add(self, other: Self) -> Self {
165        Self {
166            input_tokens: self.input_tokens.saturating_add(other.input_tokens),
167            output_tokens: self.output_tokens.saturating_add(other.output_tokens),
168            cache_read_tokens: self
169                .cache_read_tokens
170                .saturating_add(other.cache_read_tokens),
171            cache_write_tokens: self
172                .cache_write_tokens
173                .saturating_add(other.cache_write_tokens),
174        }
175    }
176
177    /// Total tokens both directions.
178    #[must_use]
179    pub const fn total(self) -> u32 {
180        self.input_tokens.saturating_add(self.output_tokens)
181    }
182}
183
184/// One chat-completions response.
185#[derive(Debug, Clone, PartialEq)]
186pub struct ChatResponse {
187    /// The model's content parts (text and/or tool calls).
188    pub content: Vec<ContentPart>,
189    /// Why the model stopped.
190    pub stop_reason: StopReason,
191    /// Provider-reported token counts.
192    pub usage: TokenUsage,
193}
194
195/// Provider-agnostic chat-completions client.
196///
197/// Object-safe: implementations box their futures by hand so the trait stays
198/// usable behind `Arc<dyn LlmClient>`.
199pub trait LlmClient: Send + Sync + std::fmt::Debug {
200    /// Send one chat request and decode the response.
201    fn chat<'a>(
202        &'a self,
203        request: &'a ChatRequest,
204    ) -> Pin<Box<dyn Future<Output = Result<ChatResponse, AgentError>> + Send + 'a>>;
205
206    /// List available model ids. Cheap and read-only: the health check uses it.
207    fn list_models(
208        &self,
209    ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, AgentError>> + Send + '_>>;
210
211    /// Human-readable provider name for logs and health details.
212    fn provider_name(&self) -> &'static str;
213}
214
215/// Build the configured provider client.
216///
217/// Reads the API key from `AGENT_API_KEY` through
218/// [`AgentConfig::api_key`]; fails when the key is missing.
219///
220/// # Errors
221///
222/// Returns [`AgentError`] with [`ErrorKind::Config`]
223/// when `AGENT_API_KEY` is not set, or with
224/// [`ErrorKind::Transport`] when the HTTP
225/// client cannot be built.
226pub fn client_from_config(config: &AgentConfig) -> Result<Arc<dyn LlmClient>, AgentError> {
227    let api_key = config.api_key()?;
228    client_from_config_with_key(config, &api_key)
229}
230
231/// Build the configured client with an explicitly provided API key.
232///
233/// Test seam for [`client_from_config`]: avoids reading `AGENT_API_KEY` from
234/// the process environment.
235pub(crate) fn client_from_config_with_key(
236    config: &AgentConfig,
237    api_key: &str,
238) -> Result<Arc<dyn LlmClient>, AgentError> {
239    let timeout = Duration::from_secs(config.request_timeout_secs);
240    let model = config.resolved_model().to_owned();
241    let base_url = config.resolved_base_url().to_owned();
242    let client: Arc<dyn LlmClient> = match config.provider {
243        crate::config::ProviderKind::OpenAiCompatible => {
244            Arc::new(OpenAiCompatibleClient::new(base_url, model, api_key)?.with_timeout(timeout)?)
245        }
246        crate::config::ProviderKind::Anthropic => Arc::new(
247            AnthropicClient::new(base_url, model, api_key)?
248                .with_timeout(timeout)?
249                .with_prompt_caching(config.prompt_caching),
250        ),
251    };
252    Ok(client)
253}
254
255/// Check an HTTP status and translate failures into [`AgentError`].
256async fn check_status(
257    response: reqwest::Response,
258    provider: &'static str,
259) -> Result<reqwest::Response, AgentError> {
260    let status = response.status();
261    if status.is_success() {
262        return Ok(response);
263    }
264    let body: String = response.text().await.unwrap_or_default();
265    let snippet: String = body.chars().take(500).collect();
266    let message = format!("{provider} answered {status}: {snippet}");
267    let kind = match status {
268        reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
269            ErrorKind::Authentication
270        }
271        reqwest::StatusCode::TOO_MANY_REQUESTS => ErrorKind::RateLimited,
272        reqwest::StatusCode::PAYLOAD_TOO_LARGE => ErrorKind::Budget,
273        _ => ErrorKind::Provider,
274    };
275    Err(AgentError::new(kind, message))
276}
277
278/// Extract model ids from a provider's `{"data": [{"id": ...}]}` envelope.
279fn model_ids(payload: &serde_json::Value) -> Vec<String> {
280    payload
281        .get("data")
282        .and_then(serde_json::Value::as_array)
283        .map(|items| {
284            items
285                .iter()
286                .filter_map(|item| item.get("id"))
287                .filter_map(serde_json::Value::as_str)
288                .map(str::to_owned)
289                .collect()
290        })
291        .unwrap_or_default()
292}
293
294/// Read one non-negative integer usage field, defaulting to 0.
295fn usage_field(usage: &serde_json::Value, key: &str) -> u32 {
296    usage
297        .get(key)
298        .and_then(serde_json::Value::as_u64)
299        .map_or(0, |n| u32::try_from(n).unwrap_or(u32::MAX))
300}
301
302/// Build a `reqwest` client with a timeout.
303fn http_client(timeout: Duration) -> Result<reqwest::Client, AgentError> {
304    reqwest::Client::builder()
305        .timeout(timeout)
306        .build()
307        .map_err(|err| {
308            AgentError::with_source(ErrorKind::Transport, "cannot build HTTP client", err)
309        })
310}
311
312/// Client for the OpenAI chat-completions protocol.
313///
314/// Also serves every OpenAI-compatible endpoint (Ollama, vLLM, LM Studio,
315/// a compatible proxy) — point `base_url` at it and keep the protocol.
316pub struct OpenAiCompatibleClient {
317    http: reqwest::Client,
318    base_url: String,
319    model: String,
320    api_key: String,
321}
322
323impl std::fmt::Debug for OpenAiCompatibleClient {
324    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325        f.debug_struct("OpenAiCompatibleClient")
326            .field("base_url", &self.base_url)
327            .field("model", &self.model)
328            .field("api_key", &"<redacted>")
329            .finish_non_exhaustive()
330    }
331}
332
333impl OpenAiCompatibleClient {
334    /// Build a client. The key never leaves this struct except as a header.
335    ///
336    /// # Errors
337    ///
338    /// Returns [`AgentError`] when the underlying HTTP client cannot be built.
339    pub fn new(
340        base_url: impl Into<String>,
341        model: impl Into<String>,
342        api_key: impl Into<String>,
343    ) -> Result<Self, AgentError> {
344        Ok(Self {
345            http: http_client(Duration::from_secs(60))?,
346            base_url: base_url.into().trim_end_matches('/').to_owned(),
347            model: model.into(),
348            api_key: api_key.into(),
349        })
350    }
351
352    /// Replace the request timeout.
353    ///
354    /// # Errors
355    ///
356    /// Returns [`AgentError`] when the underlying HTTP client cannot be rebuilt.
357    pub fn with_timeout(mut self, timeout: Duration) -> Result<Self, AgentError> {
358        self.http = http_client(timeout)?;
359        Ok(self)
360    }
361
362    /// Chat endpoint URL.
363    #[must_use]
364    pub fn chat_url(&self) -> String {
365        format!("{}/chat/completions", self.base_url)
366    }
367
368    async fn chat_inner(&self, request: &ChatRequest) -> Result<ChatResponse, AgentError> {
369        let mut body = serde_json::json!({
370            "model": self.model,
371            "messages": openai_messages(&request.messages),
372            "tools": request.tools.iter().map(openai_tool).collect::<Vec<_>>(),
373        });
374        if request.tools.is_empty() {
375            if let Some(obj) = body.as_object_mut() {
376                obj.remove("tools");
377            }
378        } else {
379            body["tool_choice"] = serde_json::Value::String("auto".to_owned());
380        }
381        if let Some(max_tokens) = request.max_tokens {
382            body["max_tokens"] = serde_json::json!(max_tokens);
383        }
384        if let Some(temperature) = request.temperature {
385            body["temperature"] = serde_json::json!(temperature);
386        }
387        let response = self
388            .http
389            .post(self.chat_url())
390            .bearer_auth(&self.api_key)
391            .json(&body)
392            .send()
393            .await
394            .map_err(AgentError::from)?;
395        let response = check_status(response, "openai-compatible").await?;
396        let payload: serde_json::Value = response.json().await.map_err(AgentError::from)?;
397        openai_response(&payload)
398    }
399}
400
401impl LlmClient for OpenAiCompatibleClient {
402    fn chat<'a>(
403        &'a self,
404        request: &'a ChatRequest,
405    ) -> Pin<Box<dyn Future<Output = Result<ChatResponse, AgentError>> + Send + 'a>> {
406        Box::pin(self.chat_inner(request))
407    }
408
409    fn list_models(
410        &self,
411    ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, AgentError>> + Send + '_>> {
412        Box::pin(async move {
413            let response = self
414                .http
415                .get(format!("{}/models", self.base_url))
416                .bearer_auth(&self.api_key)
417                .send()
418                .await
419                .map_err(AgentError::from)?;
420            let response = check_status(response, "openai-compatible").await?;
421            let payload: serde_json::Value = response.json().await.map_err(AgentError::from)?;
422            Ok(model_ids(&payload))
423        })
424    }
425
426    fn provider_name(&self) -> &'static str {
427        "openai-compatible"
428    }
429}
430
431/// Convert provider-neutral messages to the OpenAI wire format.
432fn openai_messages(messages: &[ChatMessage]) -> Vec<serde_json::Value> {
433    let mut out = Vec::new();
434    for message in messages {
435        match message.role {
436            ChatRole::System | ChatRole::User => {
437                let role = match message.role {
438                    ChatRole::System => "system",
439                    _ => "user",
440                };
441                let text = message
442                    .content
443                    .iter()
444                    .filter_map(|part| match part {
445                        ContentPart::Text(text) => Some(text.as_str()),
446                        _ => None,
447                    })
448                    .collect::<Vec<_>>()
449                    .join("");
450                out.push(serde_json::json!({"role": role, "content": text}));
451            }
452            ChatRole::Assistant => {
453                let text = message
454                    .content
455                    .iter()
456                    .filter_map(|part| match part {
457                        ContentPart::Text(text) => Some(text.as_str()),
458                        _ => None,
459                    })
460                    .collect::<Vec<_>>()
461                    .join("");
462                let tool_calls = message
463                    .content
464                    .iter()
465                    .filter_map(|part| match part {
466                        ContentPart::ToolCall {
467                            id,
468                            name,
469                            arguments,
470                        } => Some(serde_json::json!({
471                            "id": id,
472                            "type": "function",
473                            "function": {
474                                "name": name,
475                                "arguments": arguments.to_string(),
476                            }
477                        })),
478                        _ => None,
479                    })
480                    .collect::<Vec<_>>();
481                let mut value = serde_json::json!({"role": "assistant", "content": text});
482                if !tool_calls.is_empty() {
483                    value["tool_calls"] = serde_json::Value::Array(tool_calls);
484                }
485                out.push(value);
486            }
487            ChatRole::Tool => {
488                for part in &message.content {
489                    if let ContentPart::ToolResult {
490                        tool_call_id,
491                        content,
492                    } = part
493                    {
494                        out.push(serde_json::json!({
495                            "role": "tool",
496                            "tool_call_id": tool_call_id,
497                            "content": content,
498                        }));
499                    }
500                }
501            }
502        }
503    }
504    out
505}
506
507/// Convert a tool definition to the OpenAI `{"type": "function", ...}` shape.
508fn openai_tool(tool: &ToolDefinition) -> serde_json::Value {
509    serde_json::json!({
510        "type": "function",
511        "function": {
512            "name": tool.name,
513            "description": tool.description,
514            "parameters": tool.input_schema,
515        }
516    })
517}
518
519/// Decode an OpenAI chat-completions response.
520fn openai_response(payload: &serde_json::Value) -> Result<ChatResponse, AgentError> {
521    let choice = payload
522        .get("choices")
523        .and_then(serde_json::Value::as_array)
524        .and_then(|choices| choices.first())
525        .ok_or_else(|| {
526            AgentError::new(
527                ErrorKind::Decode,
528                "openai-compatible response has no choices",
529            )
530        })?;
531    let message = choice.get("message").ok_or_else(|| {
532        AgentError::new(ErrorKind::Decode, "openai-compatible choice has no message")
533    })?;
534    let mut content = Vec::new();
535    if let Some(text) = message.get("content").and_then(serde_json::Value::as_str)
536        && !text.is_empty()
537    {
538        content.push(ContentPart::Text(text.to_owned()));
539    }
540    if let Some(calls) = message
541        .get("tool_calls")
542        .and_then(serde_json::Value::as_array)
543    {
544        for call in calls {
545            let id = call
546                .get("id")
547                .and_then(serde_json::Value::as_str)
548                .unwrap_or_default()
549                .to_owned();
550            let function = call.get("function").ok_or_else(|| {
551                AgentError::new(ErrorKind::Decode, "tool_call has no function payload")
552            })?;
553            let name = function
554                .get("name")
555                .and_then(serde_json::Value::as_str)
556                .unwrap_or_default()
557                .to_owned();
558            let raw_args = function
559                .get("arguments")
560                .and_then(serde_json::Value::as_str)
561                .unwrap_or("{}");
562            let arguments: serde_json::Value = serde_json::from_str(raw_args).map_err(|err| {
563                AgentError::with_source(
564                    ErrorKind::Decode,
565                    "tool_call arguments are not valid JSON",
566                    err,
567                )
568            })?;
569            content.push(ContentPart::ToolCall {
570                id,
571                name,
572                arguments,
573            });
574        }
575    }
576    let stop_reason = match choice
577        .get("finish_reason")
578        .and_then(serde_json::Value::as_str)
579    {
580        Some("tool_calls") => StopReason::ToolUse,
581        Some("length") => StopReason::MaxTokens,
582        Some("stop") => StopReason::EndTurn,
583        _ => StopReason::Unknown,
584    };
585    // OpenAI caches long prompts on its own and reports the cached share
586    // in `prompt_tokens_details.cached_tokens` (a subset of `prompt_tokens`).
587    let usage = payload
588        .get("usage")
589        .map(|usage| TokenUsage {
590            input_tokens: usage_field(usage, "prompt_tokens"),
591            output_tokens: usage_field(usage, "completion_tokens"),
592            cache_read_tokens: usage
593                .get("prompt_tokens_details")
594                .map_or(0, |details| usage_field(details, "cached_tokens")),
595            cache_write_tokens: 0,
596        })
597        .unwrap_or_default();
598    Ok(ChatResponse {
599        content,
600        stop_reason,
601        usage,
602    })
603}
604
605/// Client for the Anthropic Messages API.
606///
607/// Prompt caching is on by default: the client marks the tool list, the
608/// system prompt, and the newest message as cache breakpoints, so each loop
609/// step and each scheduled run re-reads the stable prefix from the cache.
610pub struct AnthropicClient {
611    http: reqwest::Client,
612    base_url: String,
613    model: String,
614    api_key: String,
615    prompt_caching: bool,
616}
617
618impl std::fmt::Debug for AnthropicClient {
619    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
620        f.debug_struct("AnthropicClient")
621            .field("base_url", &self.base_url)
622            .field("model", &self.model)
623            .field("prompt_caching", &self.prompt_caching)
624            .field("api_key", &"<redacted>")
625            .finish_non_exhaustive()
626    }
627}
628
629impl AnthropicClient {
630    /// Anthropic API version header sent with every request.
631    pub const API_VERSION: &'static str = "2023-06-01";
632
633    /// Build a client. The key never leaves this struct except as a header.
634    ///
635    /// # Errors
636    ///
637    /// Returns [`AgentError`] when the underlying HTTP client cannot be built.
638    pub fn new(
639        base_url: impl Into<String>,
640        model: impl Into<String>,
641        api_key: impl Into<String>,
642    ) -> Result<Self, AgentError> {
643        Ok(Self {
644            http: http_client(Duration::from_secs(60))?,
645            base_url: base_url.into().trim_end_matches('/').to_owned(),
646            model: model.into(),
647            api_key: api_key.into(),
648            prompt_caching: true,
649        })
650    }
651
652    /// Replace the request timeout.
653    ///
654    /// # Errors
655    ///
656    /// Returns [`AgentError`] when the underlying HTTP client cannot be rebuilt.
657    pub fn with_timeout(mut self, timeout: Duration) -> Result<Self, AgentError> {
658        self.http = http_client(timeout)?;
659        Ok(self)
660    }
661
662    /// Turn the `cache_control` breakpoints on or off.
663    #[must_use]
664    pub const fn with_prompt_caching(mut self, enabled: bool) -> Self {
665        self.prompt_caching = enabled;
666        self
667    }
668
669    /// Messages endpoint URL.
670    #[must_use]
671    pub fn messages_url(&self) -> String {
672        format!("{}/v1/messages", self.base_url)
673    }
674
675    async fn chat_inner(&self, request: &ChatRequest) -> Result<ChatResponse, AgentError> {
676        let (system, mut messages) = anthropic_messages(&request.messages);
677        let mut tools = request.tools.iter().map(anthropic_tool).collect::<Vec<_>>();
678        if self.prompt_caching {
679            mark_cache_breakpoints(&mut tools, &mut messages);
680        }
681        let mut body = serde_json::json!({
682            "model": self.model,
683            "max_tokens": request.max_tokens.unwrap_or(1024),
684            "messages": messages,
685            "tools": tools,
686        });
687        if let Some(system) = system {
688            body["system"] = if self.prompt_caching {
689                serde_json::json!([{
690                    "type": "text",
691                    "text": system,
692                    "cache_control": {"type": "ephemeral"},
693                }])
694            } else {
695                serde_json::Value::String(system)
696            };
697        }
698        if let Some(temperature) = request.temperature {
699            body["temperature"] = serde_json::json!(temperature);
700        }
701        let response = self
702            .http
703            .post(self.messages_url())
704            .header("x-api-key", &self.api_key)
705            .header("anthropic-version", Self::API_VERSION)
706            .json(&body)
707            .send()
708            .await
709            .map_err(AgentError::from)?;
710        let response = check_status(response, "anthropic").await?;
711        let payload: serde_json::Value = response.json().await.map_err(AgentError::from)?;
712        anthropic_response(&payload)
713    }
714}
715
716impl LlmClient for AnthropicClient {
717    fn chat<'a>(
718        &'a self,
719        request: &'a ChatRequest,
720    ) -> Pin<Box<dyn Future<Output = Result<ChatResponse, AgentError>> + Send + 'a>> {
721        Box::pin(self.chat_inner(request))
722    }
723
724    fn list_models(
725        &self,
726    ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, AgentError>> + Send + '_>> {
727        Box::pin(async move {
728            let response = self
729                .http
730                .get(format!("{}/v1/models", self.base_url))
731                .header("x-api-key", &self.api_key)
732                .header("anthropic-version", Self::API_VERSION)
733                .send()
734                .await
735                .map_err(AgentError::from)?;
736            let response = check_status(response, "anthropic").await?;
737            let payload: serde_json::Value = response.json().await.map_err(AgentError::from)?;
738            Ok(model_ids(&payload))
739        })
740    }
741
742    fn provider_name(&self) -> &'static str {
743        "anthropic"
744    }
745}
746
747/// Split provider-neutral messages into Anthropic's top-level `system` plus
748/// the `messages` array.
749fn anthropic_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<serde_json::Value>) {
750    let mut system_parts = Vec::new();
751    let mut out = Vec::new();
752    for message in messages {
753        match message.role {
754            ChatRole::System => {
755                for part in &message.content {
756                    if let ContentPart::Text(text) = part {
757                        system_parts.push(text.clone());
758                    }
759                }
760            }
761            ChatRole::User | ChatRole::Assistant | ChatRole::Tool => {
762                let role = match message.role {
763                    ChatRole::Assistant => "assistant",
764                    _ => "user",
765                };
766                let blocks = message
767                    .content
768                    .iter()
769                    .map(|part| match part {
770                        ContentPart::Text(text) => {
771                            serde_json::json!({"type": "text", "text": text})
772                        }
773                        ContentPart::ToolCall {
774                            id,
775                            name,
776                            arguments,
777                        } => serde_json::json!({
778                            "type": "tool_use",
779                            "id": id,
780                            "name": name,
781                            "input": arguments,
782                        }),
783                        ContentPart::ToolResult {
784                            tool_call_id,
785                            content,
786                        } => serde_json::json!({
787                            "type": "tool_result",
788                            "tool_use_id": tool_call_id,
789                            "content": content,
790                        }),
791                    })
792                    .collect::<Vec<_>>();
793                if !blocks.is_empty() {
794                    out.push(serde_json::json!({"role": role, "content": blocks}));
795                }
796            }
797        }
798    }
799    let system = if system_parts.is_empty() {
800        None
801    } else {
802        Some(system_parts.join("\n"))
803    };
804    (system, out)
805}
806
807/// Mark the last tool and the last block of the newest message as
808/// `cache_control` breakpoints.
809///
810/// With the system breakpoint that makes three of Anthropic's four allowed
811/// breakpoints. The prefix up to each breakpoint is cached, so the next loop
812/// step pays full price only for the new turn.
813fn mark_cache_breakpoints(tools: &mut [serde_json::Value], messages: &mut [serde_json::Value]) {
814    let ephemeral = serde_json::json!({"type": "ephemeral"});
815    if let Some(serde_json::Value::Object(tool)) = tools.last_mut() {
816        tool.insert("cache_control".to_owned(), ephemeral.clone());
817    }
818    if let Some(serde_json::Value::Object(block)) = messages
819        .last_mut()
820        .and_then(|message| message.get_mut("content"))
821        .and_then(serde_json::Value::as_array_mut)
822        .and_then(|blocks| blocks.last_mut())
823    {
824        block.insert("cache_control".to_owned(), ephemeral);
825    }
826}
827
828/// Convert a tool definition to the Anthropic `input_schema` shape.
829fn anthropic_tool(tool: &ToolDefinition) -> serde_json::Value {
830    serde_json::json!({
831        "name": tool.name,
832        "description": tool.description,
833        "input_schema": tool.input_schema,
834    })
835}
836
837/// Decode an Anthropic Messages response.
838fn anthropic_response(payload: &serde_json::Value) -> Result<ChatResponse, AgentError> {
839    let blocks = payload
840        .get("content")
841        .and_then(serde_json::Value::as_array)
842        .ok_or_else(|| {
843            AgentError::new(
844                ErrorKind::Decode,
845                "anthropic response has no content blocks",
846            )
847        })?;
848    let mut content = Vec::new();
849    for block in blocks {
850        let block_type = block.get("type").and_then(serde_json::Value::as_str);
851        match block_type {
852            Some("text") => {
853                if let Some(text) = block.get("text").and_then(serde_json::Value::as_str) {
854                    content.push(ContentPart::Text(text.to_owned()));
855                }
856            }
857            Some("tool_use") => {
858                let id = block
859                    .get("id")
860                    .and_then(serde_json::Value::as_str)
861                    .unwrap_or_default()
862                    .to_owned();
863                let name = block
864                    .get("name")
865                    .and_then(serde_json::Value::as_str)
866                    .unwrap_or_default()
867                    .to_owned();
868                let arguments = block
869                    .get("input")
870                    .cloned()
871                    .unwrap_or(serde_json::Value::Null);
872                content.push(ContentPart::ToolCall {
873                    id,
874                    name,
875                    arguments,
876                });
877            }
878            _ => {}
879        }
880    }
881    let stop_reason = match payload
882        .get("stop_reason")
883        .and_then(serde_json::Value::as_str)
884    {
885        Some("tool_use") => StopReason::ToolUse,
886        Some("max_tokens") => StopReason::MaxTokens,
887        Some("end_turn") => StopReason::EndTurn,
888        _ => StopReason::Unknown,
889    };
890    // Anthropic reports `input_tokens` without the cached share; add the
891    // cache reads and writes back so `input_tokens` is the full prompt size
892    // on both providers and budgets stay honest.
893    let usage = payload
894        .get("usage")
895        .map(|usage| {
896            let cache_read_tokens = usage_field(usage, "cache_read_input_tokens");
897            let cache_write_tokens = usage_field(usage, "cache_creation_input_tokens");
898            TokenUsage {
899                input_tokens: usage_field(usage, "input_tokens")
900                    .saturating_add(cache_read_tokens)
901                    .saturating_add(cache_write_tokens),
902                output_tokens: usage_field(usage, "output_tokens"),
903                cache_read_tokens,
904                cache_write_tokens,
905            }
906        })
907        .unwrap_or_default();
908    Ok(ChatResponse {
909        content,
910        stop_reason,
911        usage,
912    })
913}
914
915#[cfg(test)]
916mod tests;