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::with_base_url(api_key, None)
26 }
27
28 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 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 #[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, schema: None,
131 tools: None,
132 tool_choice: None,
133 };
134 let body = client.build_body(&request, false);
135 assert_eq!(body["model"], "claude-sonnet-4-6");
137 }
138}