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::with_base_url(api_key, None)
26    }
27
28    /// Create a provider with an explicit base-URL override (passed through to the
29    /// underlying [`AnthropicClient`]). `Some(url)` is used verbatim; `None` falls back
30    /// to `ANTHROPIC_BASE_URL` / the default endpoint. Lets a caller point the client at a
31    /// per-call mock server without mutating the process-global env var. `new(api_key)`
32    /// delegates here with `None`.
33    pub fn with_base_url(api_key: String, base_url: Option<String>) -> Self {
34        Self {
35            client: Arc::new(AnthropicClient::new_with_base_url(api_key, None, base_url)),
36        }
37    }
38
39    /// Create a provider reading the API key from `ANTHROPIC_API_KEY`.
40    pub fn from_env() -> Result<Self, Error> {
41        let api_key = std::env::var("ANTHROPIC_API_KEY")
42            .map_err(|_| Error::Config("ANTHROPIC_API_KEY not set".to_string()))?;
43        Ok(Self::new(api_key))
44    }
45}
46
47#[async_trait]
48impl ClassificationProvider for AnthropicProvider {
49    async fn classify_raw(
50        &self,
51        system_prompt: &str,
52        user_prompt: &str,
53        schema: &serde_json::Value,
54        config: &ClassifierConfig,
55    ) -> Result<serde_json::Value, Error> {
56        let request = CompletionRequest {
57            system: Some(system_prompt.to_string()),
58            messages: vec![Message {
59                role: Role::User,
60                content: user_prompt.to_string(),
61                tool_call_id: None,
62            }],
63            max_tokens: config.max_tokens,
64            model_override: if config.model.is_empty() {
65                None
66            } else {
67                Some(config.model.clone())
68            },
69            schema: Some(schema.clone()),
70            tools: None,
71            tool_choice: None,
72        };
73        let text = self.client.complete(request).await?;
74        serde_json::from_str(&text).map_err(|e| Error::Deserialization(e.to_string()))
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use crate::client::anthropic::AnthropicClient;
81    use crate::client::{CompletionRequest, Message, Role};
82
83    /// Verify the CompletionRequest built for classification carries the schema
84    /// and model_override fields correctly. Exercises AnthropicClient::build_body
85    /// (the body-shape assertions moved to client/anthropic.rs in Plan 02).
86    #[test]
87    fn test_classify_request_shape_with_explicit_model() {
88        let client = AnthropicClient::new("k".into(), None);
89        let schema =
90            serde_json::json!({"type": "object", "properties": {"category": {"type": "string"}}});
91        let request = CompletionRequest {
92            system: Some("You classify intents.".into()),
93            messages: vec![Message {
94                role: Role::User,
95                content: "Hello world".into(),
96                tool_call_id: None,
97            }],
98            max_tokens: 1024,
99            model_override: Some("claude-opus-4-6".into()),
100            schema: Some(schema.clone()),
101            tools: None,
102            tool_choice: None,
103        };
104        let body = client.build_body(&request, false);
105
106        assert_eq!(body["model"], "claude-opus-4-6");
107        assert_eq!(body["max_tokens"], 1024);
108        assert_eq!(body["output_config"]["format"]["type"], "json_schema");
109        assert_eq!(body["output_config"]["format"]["schema"], schema);
110        let system = &body["system"][0];
111        assert_eq!(system["type"], "text");
112        assert_eq!(system["text"], "You classify intents.");
113        assert_eq!(system["cache_control"]["type"], "ephemeral");
114        assert_eq!(body["messages"][0]["role"], "user");
115        assert_eq!(body["messages"][0]["content"], "Hello world");
116    }
117
118    #[test]
119    fn test_classify_request_empty_model_uses_client_default() {
120        let client = AnthropicClient::new("k".into(), None);
121        let request = CompletionRequest {
122            system: None,
123            messages: vec![Message {
124                role: Role::User,
125                content: "hi".into(),
126                tool_call_id: None,
127            }],
128            max_tokens: 512,
129            model_override: None, // empty config.model → no override
130            schema: None,
131            tools: None,
132            tool_choice: None,
133        };
134        let body = client.build_body(&request, false);
135        // AnthropicClient::default_model() returns "claude-sonnet-4-6"
136        assert_eq!(body["model"], "claude-sonnet-4-6");
137    }
138}