1pub mod anthropic;
18pub mod null;
19
20use crate::config::{LlmConfig, LlmProvider};
21use std::sync::Arc;
22use std::sync::atomic::{AtomicU32, Ordering};
23
24pub use anthropic::AnthropicLlmClient;
25pub use null::NullLlmClient;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct LlmVerdict {
30 pub match_spec: bool,
32 pub reason: String,
34}
35
36pub trait LlmClient: Send + Sync {
38 fn evaluate(&self, system_prompt: &str, user_prompt: &str) -> Option<LlmVerdict>;
42}
43
44pub struct BudgetedClient {
46 inner: Arc<dyn LlmClient>,
47 remaining: AtomicU32,
48}
49
50impl BudgetedClient {
51 pub fn new(inner: Arc<dyn LlmClient>, budget: u32) -> Self {
52 Self {
53 inner,
54 remaining: AtomicU32::new(budget),
55 }
56 }
57}
58
59impl LlmClient for BudgetedClient {
60 fn evaluate(&self, system_prompt: &str, user_prompt: &str) -> Option<LlmVerdict> {
61 loop {
63 let current = self.remaining.load(Ordering::Acquire);
64 if current == 0 {
65 return None;
66 }
67 if self
68 .remaining
69 .compare_exchange(current, current - 1, Ordering::AcqRel, Ordering::Acquire)
70 .is_ok()
71 {
72 break;
73 }
74 }
75 self.inner.evaluate(system_prompt, user_prompt)
76 }
77}
78
79pub fn build_client(cfg: &LlmConfig, force_off: bool) -> Arc<dyn LlmClient> {
86 if force_off || !cfg.enabled {
87 return Arc::new(NullLlmClient);
88 }
89
90 let inner: Arc<dyn LlmClient> = match cfg.provider {
91 LlmProvider::Anthropic => match AnthropicLlmClient::from_env(cfg.model.clone(), cfg.timeout_s)
92 {
93 Some(c) => Arc::new(c),
94 None => {
95 eprintln!(
96 "spec-drift: [llm] enabled but ANTHROPIC_API_KEY is not set; \
97 skipping LLM rules for this run."
98 );
99 Arc::new(NullLlmClient)
100 }
101 },
102 LlmProvider::OpenAi | LlmProvider::Local => {
103 eprintln!(
104 "spec-drift: [llm].provider {:?} is not implemented yet; \
105 skipping LLM rules for this run.",
106 cfg.provider
107 );
108 Arc::new(NullLlmClient)
109 }
110 };
111
112 Arc::new(BudgetedClient::new(inner, cfg.max_calls))
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use std::sync::atomic::{AtomicU32, Ordering};
119
120 struct CountingClient {
121 calls: AtomicU32,
122 }
123
124 impl LlmClient for CountingClient {
125 fn evaluate(&self, _: &str, _: &str) -> Option<LlmVerdict> {
126 self.calls.fetch_add(1, Ordering::AcqRel);
127 Some(LlmVerdict {
128 match_spec: true,
129 reason: "ok".into(),
130 })
131 }
132 }
133
134 #[test]
135 fn budget_exhausts_and_stops_calling_inner() {
136 let inner = Arc::new(CountingClient {
137 calls: AtomicU32::new(0),
138 });
139 let client = BudgetedClient::new(inner.clone(), 2);
140
141 assert!(client.evaluate("s", "u").is_some());
142 assert!(client.evaluate("s", "u").is_some());
143 assert!(client.evaluate("s", "u").is_none());
144 assert!(client.evaluate("s", "u").is_none());
145
146 assert_eq!(inner.calls.load(Ordering::Acquire), 2);
147 }
148
149 #[test]
150 fn force_off_always_yields_null_client() {
151 let cfg = LlmConfig {
152 enabled: true,
153 ..LlmConfig::default()
154 };
155 let client = build_client(&cfg, true);
156 assert!(client.evaluate("s", "u").is_none());
157 }
158
159 #[test]
160 fn disabled_config_yields_null_client() {
161 let cfg = LlmConfig::default(); let client = build_client(&cfg, false);
163 assert!(client.evaluate("s", "u").is_none());
164 }
165}