Skip to main content

ferro_ai/classifier/
anthropic.rs

1use crate::client::anthropic::AnthropicClient;
2use crate::client::{CompletionRequest, LlmClient, Message, Role};
3use crate::error::Error;
4use async_trait::async_trait;
5use std::sync::Arc;
6
7use super::{ClassificationProvider, ClassifierConfig};
8
9/// Anthropic API-based classification provider.
10///
11/// Thin adapter over [`AnthropicClient`]. All HTTP logic lives in the client;
12/// this type builds the [`CompletionRequest`] and delegates (D-10).
13///
14/// # Authentication
15///
16/// Requires an `ANTHROPIC_API_KEY` environment variable or an explicit API key
17/// passed to [`AnthropicProvider::new`].
18pub struct AnthropicProvider {
19    client: Arc<AnthropicClient>,
20}
21
22impl AnthropicProvider {
23    /// Create a new provider with an explicit API key.
24    pub fn new(api_key: String) -> Self {
25        Self {
26            client: Arc::new(AnthropicClient::new(api_key, None)),
27        }
28    }
29
30    /// Create a provider reading the API key from `ANTHROPIC_API_KEY`.
31    pub fn from_env() -> Result<Self, Error> {
32        let api_key = std::env::var("ANTHROPIC_API_KEY")
33            .map_err(|_| Error::Config("ANTHROPIC_API_KEY not set".to_string()))?;
34        Ok(Self::new(api_key))
35    }
36}
37
38#[async_trait]
39impl ClassificationProvider for AnthropicProvider {
40    async fn classify_raw(
41        &self,
42        system_prompt: &str,
43        user_prompt: &str,
44        schema: &serde_json::Value,
45        config: &ClassifierConfig,
46    ) -> Result<serde_json::Value, Error> {
47        let request = CompletionRequest {
48            system: Some(system_prompt.to_string()),
49            messages: vec![Message {
50                role: Role::User,
51                content: user_prompt.to_string(),
52                tool_call_id: None,
53            }],
54            max_tokens: config.max_tokens,
55            model_override: if config.model.is_empty() {
56                None
57            } else {
58                Some(config.model.clone())
59            },
60            schema: Some(schema.clone()),
61            tools: None,
62            tool_choice: None,
63        };
64        let text = self.client.complete(request).await?;
65        serde_json::from_str(&text).map_err(|e| Error::Deserialization(e.to_string()))
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use crate::client::anthropic::AnthropicClient;
72    use crate::client::{CompletionRequest, Message, Role};
73
74    /// Verify the CompletionRequest built for classification carries the schema
75    /// and model_override fields correctly. Exercises AnthropicClient::build_body
76    /// (the body-shape assertions moved to client/anthropic.rs in Plan 02).
77    #[test]
78    fn test_classify_request_shape_with_explicit_model() {
79        let client = AnthropicClient::new("k".into(), None);
80        let schema =
81            serde_json::json!({"type": "object", "properties": {"category": {"type": "string"}}});
82        let request = CompletionRequest {
83            system: Some("You classify intents.".into()),
84            messages: vec![Message {
85                role: Role::User,
86                content: "Hello world".into(),
87                tool_call_id: None,
88            }],
89            max_tokens: 1024,
90            model_override: Some("claude-opus-4-6".into()),
91            schema: Some(schema.clone()),
92            tools: None,
93            tool_choice: None,
94        };
95        let body = client.build_body(&request, false);
96
97        assert_eq!(body["model"], "claude-opus-4-6");
98        assert_eq!(body["max_tokens"], 1024);
99        assert_eq!(body["output_config"]["format"]["type"], "json_schema");
100        assert_eq!(body["output_config"]["format"]["schema"], schema);
101        let system = &body["system"][0];
102        assert_eq!(system["type"], "text");
103        assert_eq!(system["text"], "You classify intents.");
104        assert_eq!(system["cache_control"]["type"], "ephemeral");
105        assert_eq!(body["messages"][0]["role"], "user");
106        assert_eq!(body["messages"][0]["content"], "Hello world");
107    }
108
109    #[test]
110    fn test_classify_request_empty_model_uses_client_default() {
111        let client = AnthropicClient::new("k".into(), None);
112        let request = CompletionRequest {
113            system: None,
114            messages: vec![Message {
115                role: Role::User,
116                content: "hi".into(),
117                tool_call_id: None,
118            }],
119            max_tokens: 512,
120            model_override: None, // empty config.model → no override
121            schema: None,
122            tools: None,
123            tool_choice: None,
124        };
125        let body = client.build_body(&request, false);
126        // AnthropicClient::default_model() returns "claude-sonnet-4-6"
127        assert_eq!(body["model"], "claude-sonnet-4-6");
128    }
129}