Skip to main content

tj_core/classifier/
http.rs

1//! Anthropic API HTTP client implementing Classifier.
2
3use super::*;
4use anyhow::{anyhow, Context};
5use serde::{Deserialize, Serialize};
6use std::time::Duration;
7
8/// Default upper bound on a single classification round-trip. Hooks wrap calls
9/// in `|| true` so a timeout never breaks Claude Code, but without a bound the
10/// hook would still hang the chat turn.
11pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
12
13/// Default model when `TJ_CLASSIFIER_MODEL` is not set.
14pub const DEFAULT_MODEL: &str = "claude-haiku-4-5-20251001";
15
16pub struct AnthropicClassifier {
17    pub api_key: String,
18    pub model: String,
19    pub base_url: String, // overridable for tests
20    pub timeout: Duration,
21}
22
23impl AnthropicClassifier {
24    pub fn from_env() -> anyhow::Result<Self> {
25        let api_key =
26            std::env::var("ANTHROPIC_API_KEY").context("ANTHROPIC_API_KEY env var not set")?;
27        let model = std::env::var("TJ_CLASSIFIER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
28        Ok(Self {
29            api_key,
30            model,
31            base_url: "https://api.anthropic.com".into(),
32            timeout: DEFAULT_TIMEOUT,
33        })
34    }
35}
36
37#[derive(Serialize)]
38struct MessagesRequest<'a> {
39    model: &'a str,
40    max_tokens: u32,
41    messages: Vec<MessageIn<'a>>,
42}
43#[derive(Serialize)]
44struct MessageIn<'a> {
45    role: &'a str,
46    content: &'a str,
47}
48#[derive(Deserialize)]
49struct MessagesResponse {
50    content: Vec<ContentBlock>,
51}
52#[derive(Deserialize)]
53struct ContentBlock {
54    #[serde(rename = "type")]
55    kind: String,
56    #[serde(default)]
57    text: String,
58}
59
60impl Classifier for AnthropicClassifier {
61    fn classify(&self, input: &ClassifyInput) -> anyhow::Result<ClassifyOutput> {
62        let prompt = crate::classifier::prompt::build(input);
63        let body = MessagesRequest {
64            model: &self.model,
65            max_tokens: 256,
66            messages: vec![MessageIn {
67                role: "user",
68                content: &prompt,
69            }],
70        };
71
72        let url = format!("{}/v1/messages", self.base_url);
73        let resp: MessagesResponse = ureq::post(&url)
74            .timeout(self.timeout)
75            .set("x-api-key", &self.api_key)
76            .set("anthropic-version", "2023-06-01")
77            .set("content-type", "application/json")
78            .send_json(serde_json::to_value(&body)?)
79            .context("Anthropic API request failed")?
80            .into_json()
81            .context("decode Anthropic response")?;
82
83        let text = resp
84            .content
85            .iter()
86            .find(|b| b.kind == "text")
87            .map(|b| b.text.clone())
88            .ok_or_else(|| anyhow!("no text content in response"))?;
89
90        super::parse_verdict(&text)
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::event::EventType;
98
99    #[test]
100    fn classifier_parses_anthropic_response() {
101        let mut server = mockito::Server::new();
102        let url = server.url();
103
104        let body = serde_json::json!({
105            "id": "msg_test",
106            "type": "message",
107            "role": "assistant",
108            "model": "claude-haiku-4-5-20251001",
109            "content": [
110                { "type": "text", "text": "{\"event_type\":\"decision\",\"task_id_guess\":\"tj-x\",\"confidence\":0.93,\"evidence_strength\":null,\"suggested_text\":\"Adopt Rust.\"}" }
111            ],
112            "stop_reason": "end_turn"
113        });
114
115        let mock = server
116            .mock("POST", "/v1/messages")
117            .with_status(200)
118            .with_header("content-type", "application/json")
119            .with_body(body.to_string())
120            .create();
121
122        let c = AnthropicClassifier {
123            api_key: "test".into(),
124            model: "claude-haiku-4-5-20251001".into(),
125            base_url: url,
126            timeout: DEFAULT_TIMEOUT,
127        };
128        let out = c
129            .classify(&ClassifyInput {
130                text: "We adopted Rust.".into(),
131                author_hint: "assistant".into(),
132                recent_tasks: vec![],
133            })
134            .unwrap();
135
136        assert_eq!(out.event_type, EventType::Decision);
137        assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
138        assert!((out.confidence - 0.93).abs() < 1e-6);
139        mock.assert();
140    }
141
142    #[test]
143    fn classifier_times_out_on_unresponsive_server() {
144        use std::net::TcpListener;
145        use std::time::Instant;
146
147        // Bind a TCP socket but never accept — the kernel completes the
148        // 3-way handshake from the backlog so connect() succeeds, but no
149        // bytes are ever read or written. Read timeout must fire.
150        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
151        let addr = listener.local_addr().unwrap();
152        let url = format!("http://{addr}");
153
154        let c = AnthropicClassifier {
155            api_key: "test".into(),
156            model: "test-model".into(),
157            base_url: url,
158            timeout: Duration::from_millis(300),
159        };
160
161        let start = Instant::now();
162        let res = c.classify(&ClassifyInput {
163            text: "x".into(),
164            author_hint: "user".into(),
165            recent_tasks: vec![],
166        });
167        let elapsed = start.elapsed();
168
169        assert!(res.is_err(), "expected a timeout error, got Ok");
170        assert!(
171            elapsed < Duration::from_secs(3),
172            "expected timeout near 300ms, got {elapsed:?}"
173        );
174
175        // Keep the listener alive until after the request to avoid races.
176        drop(listener);
177    }
178}