ferro_ai/classifier/
anthropic.rs1use 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
9pub struct AnthropicProvider {
19 client: Arc<AnthropicClient>,
20}
21
22impl AnthropicProvider {
23 pub fn new(api_key: String) -> Self {
25 Self {
26 client: Arc::new(AnthropicClient::new(api_key, None)),
27 }
28 }
29
30 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 #[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, schema: None,
122 tools: None,
123 tool_choice: None,
124 };
125 let body = client.build_body(&request, false);
126 assert_eq!(body["model"], "claude-sonnet-4-6");
128 }
129}