looprs 0.4.0

Concise coding assistant REPL — core library
Documentation
use claudius::{Anthropic, MessageCreateParams};
use serde_json::{Value, json};

use crate::api::ContentBlock;
use crate::errors::ProviderError;
use crate::types::ModelId;

use super::{InferenceRequest, InferenceResponse, LLMProvider, Usage};

pub struct AnthropicSdkProvider {
    client: Anthropic,
    key: String,
    model: ModelId,
}

impl AnthropicSdkProvider {
    pub fn new(key: String) -> Result<Self, ProviderError> {
        let model = std::env::var("MODEL").ok().map(ModelId::new);
        Self::new_with_model(key, model)
    }

    pub fn new_with_model(key: String, model: Option<ModelId>) -> Result<Self, ProviderError> {
        let model = model.unwrap_or_else(ModelId::claude_opus);
        let client = Anthropic::new(Some(key.clone()))
            .map_err(|e| ProviderError::Config(format!("Anthropic SDK setup error: {e}")))?;

        Ok(Self { client, key, model })
    }
}

#[async_trait::async_trait]
impl LLMProvider for AnthropicSdkProvider {
    async fn infer(
        &self,
        req: &InferenceRequest,
    ) -> Result<InferenceResponse, Box<dyn std::error::Error + Send + Sync>> {
        let mut body = json!({
            "model": req.model.as_str(),
            "max_tokens": req.max_tokens,
            "system": req.system,
            "messages": req.messages,
            "tools": req.tools
                .iter()
                .map(|t| json!({
                    "name": t.name,
                    "description": t.description,
                    "input_schema": t.input_schema,
                }))
                .collect::<Vec<_>>(),
        });

        if let Some(temp) = req.temperature {
            body["temperature"] = json!(temp);
        }

        let params: MessageCreateParams = serde_json::from_value(body)?;
        let response = self
            .client
            .send(params)
            .await
            .map_err(|e| ProviderError::ApiError(format!("Anthropic SDK Error: {e}")))?;

        let response_json: Value = serde_json::to_value(response)?;

        let content_arr = response_json["content"].as_array().ok_or_else(|| {
            ProviderError::InvalidResponse(
                "Unexpected API response: missing 'content' array".to_string(),
            )
        })?;

        let mut blocks = Vec::new();

        for block in content_arr {
            let block_type = block.get("type").and_then(|v| v.as_str());

            match block_type {
                Some("text") => {
                    if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
                        blocks.push(ContentBlock::Text {
                            text: text.to_string(),
                        });
                    }
                }
                Some("tool_use") => {
                    let id = block.get("id").and_then(|v| v.as_str()).ok_or_else(|| {
                        ProviderError::InvalidResponse("Missing tool_use id".to_string())
                    })?;
                    let name = block.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
                        ProviderError::InvalidResponse("Missing tool_use name".to_string())
                    })?;
                    let input = block.get("input").cloned().ok_or_else(|| {
                        ProviderError::InvalidResponse("Missing tool_use input".to_string())
                    })?;

                    blocks.push(ContentBlock::ToolUse {
                        id: crate::types::ToolId::new(id),
                        name: crate::types::ToolName::new(name),
                        input,
                    });
                }
                _ => {}
            }
        }

        let stop_reason = response_json
            .get("stop_reason")
            .and_then(|v| v.as_str())
            .unwrap_or("end_turn")
            .to_string();

        let usage = if let Some(usage_obj) = response_json.get("usage") {
            Usage {
                input_tokens: usage_obj
                    .get("input_tokens")
                    .and_then(|v| v.as_u64())
                    .unwrap_or(0) as u32,
                output_tokens: usage_obj
                    .get("output_tokens")
                    .and_then(|v| v.as_u64())
                    .unwrap_or(0) as u32,
            }
        } else {
            Usage {
                input_tokens: 0,
                output_tokens: 0,
            }
        };

        Ok(InferenceResponse {
            content: blocks,
            stop_reason,
            usage,
        })
    }

    fn name(&self) -> &str {
        "anthropic-sdk"
    }

    fn model(&self) -> &ModelId {
        &self.model
    }

    fn validate_config(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        if self.key.is_empty() {
            return Err(ProviderError::Config("Anthropic API key is empty".to_string()).into());
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use looprs_core::ports::test_contracts::assert_inference_provider_contract;

    #[test]
    fn anthropic_sdk_provider_satisfies_inference_provider_contract() {
        let p = AnthropicSdkProvider::new("test-key".to_string())
            .expect("AnthropicSdkProvider::new must succeed in test");
        assert_inference_provider_contract(&p);
    }
}