appctl 0.12.1

CLI: sync OpenAPI, databases, and frameworks into LLM tool definitions; chat, run, and HTTP serve.
Documentation
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};

use crate::{
    ai::{AgentStep, LlmProvider, Message, ToolCall},
    config::ResolvedProvider,
    term::format_api_error_summary,
    tools::ToolDef,
};

pub struct AnthropicProvider {
    client: reqwest::Client,
    config: ResolvedProvider,
}

impl AnthropicProvider {
    pub fn new(config: ResolvedProvider) -> Self {
        Self {
            client: reqwest::Client::new(),
            config,
        }
    }
}

#[async_trait::async_trait]
impl LlmProvider for AnthropicProvider {
    async fn chat(&self, messages: &[Message], tools: &[ToolDef]) -> Result<AgentStep> {
        let mut request = self
            .client
            .post(format!(
                "{}/v1/messages",
                self.config.base_url.trim_end_matches('/')
            ))
            .header("anthropic-version", "2023-06-01");
        if let Some(api_key) = self.config.auth.api_key() {
            request = request.header("x-api-key", api_key);
        }
        if let Some(token) = self.config.auth.bearer_token() {
            request = request.bearer_auth(token);
        }
        for (name, value) in &self.config.extra_headers {
            request = request.header(name, value);
        }

        let payload = json!({
            "model": self.config.model,
            "max_tokens": 2048,
            "system": messages
                .iter()
                .find(|message| message.role == "system")
                .map(|message| message.content.clone())
                .unwrap_or_default(),
            "messages": messages
                .iter()
                .filter(|message| message.role != "system")
                .map(|message| json!({
                    "role": if message.role == "tool" { "user".to_string() } else { message.role.clone() },
                    "content": message.content
                }))
                .collect::<Vec<_>>(),
            "tools": tools.iter().map(|tool| json!({
                "name": tool.name,
                "description": tool.description,
                "input_schema": tool.input_schema
            })).collect::<Vec<_>>()
        });

        let response = request
            .json(&payload)
            .send()
            .await
            .context("failed to call Anthropic API")?;
        let status = response.status();
        let body = response
            .text()
            .await
            .context("failed to read Anthropic response body")?;
        if !status.is_success() {
            bail!(
                "Anthropic API returned {}: {}",
                status,
                format_api_error_summary(&body)
            );
        }
        let response: Value = serde_json::from_str(&body).with_context(|| {
            format!(
                "failed to parse Anthropic response as JSON: {}",
                format_api_error_summary(&body)
            )
        })?;

        let Some(content) = response.get("content").and_then(Value::as_array) else {
            return Ok(AgentStep::Stop);
        };

        let mut tool_calls = Vec::new();
        let mut text = String::new();
        for block in content {
            match block
                .get("type")
                .and_then(Value::as_str)
                .unwrap_or_default()
            {
                "text" => {
                    text.push_str(
                        block
                            .get("text")
                            .and_then(Value::as_str)
                            .unwrap_or_default(),
                    );
                }
                "tool_use" => {
                    tool_calls.push(ToolCall {
                        id: block
                            .get("id")
                            .and_then(Value::as_str)
                            .unwrap_or("tool")
                            .to_string(),
                        name: block
                            .get("name")
                            .and_then(Value::as_str)
                            .unwrap_or_default()
                            .to_string(),
                        arguments: block.get("input").cloned().unwrap_or(Value::Null),
                    });
                }
                _ => {}
            }
        }

        if !tool_calls.is_empty() {
            Ok(AgentStep::ToolCalls { calls: tool_calls })
        } else if text.is_empty() {
            Ok(AgentStep::Stop)
        } else {
            Ok(AgentStep::Message { content: text })
        }
    }
}