1pub mod anthropic;
18pub mod null;
19pub mod openai;
20
21use crate::config::{LlmConfig, LlmProvider};
22use std::sync::Arc;
23use std::sync::atomic::{AtomicU32, Ordering};
24
25pub use anthropic::AnthropicLlmClient;
26pub use null::NullLlmClient;
27pub use openai::OpenAiLlmClient;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct LlmVerdict {
32 pub match_spec: bool,
34 pub reason: String,
36}
37
38pub trait LlmClient: Send + Sync {
40 fn evaluate(&self, system_prompt: &str, user_prompt: &str) -> Option<LlmVerdict>;
44
45 fn complete(&self, system_prompt: &str, user_prompt: &str) -> Option<String>;
48}
49
50pub struct BudgetedClient {
52 inner: Arc<dyn LlmClient>,
53 remaining: AtomicU32,
54}
55
56impl BudgetedClient {
57 pub fn new(inner: Arc<dyn LlmClient>, budget: u32) -> Self {
58 Self {
59 inner,
60 remaining: AtomicU32::new(budget),
61 }
62 }
63}
64
65impl LlmClient for BudgetedClient {
66 fn evaluate(&self, system_prompt: &str, user_prompt: &str) -> Option<LlmVerdict> {
67 loop {
69 let current = self.remaining.load(Ordering::Acquire);
70 if current == 0 {
71 return None;
72 }
73 if self
74 .remaining
75 .compare_exchange(current, current - 1, Ordering::AcqRel, Ordering::Acquire)
76 .is_ok()
77 {
78 break;
79 }
80 }
81 self.inner.evaluate(system_prompt, user_prompt)
82 }
83
84 fn complete(&self, system_prompt: &str, user_prompt: &str) -> Option<String> {
85 loop {
86 let current = self.remaining.load(Ordering::Acquire);
87 if current == 0 {
88 return None;
89 }
90 if self
91 .remaining
92 .compare_exchange(current, current - 1, Ordering::AcqRel, Ordering::Acquire)
93 .is_ok()
94 {
95 break;
96 }
97 }
98 self.inner.complete(system_prompt, user_prompt)
99 }
100}
101
102pub fn build_client(cfg: &LlmConfig, force_off: bool) -> Arc<dyn LlmClient> {
109 if force_off || !cfg.enabled {
110 return Arc::new(NullLlmClient);
111 }
112
113 let inner: Arc<dyn LlmClient> = match cfg.provider {
114 LlmProvider::Anthropic => {
115 match AnthropicLlmClient::from_env(cfg.model.clone(), cfg.timeout_s) {
116 Some(c) => Arc::new(c),
117 None => {
118 eprintln!(
119 "spec-drift: [llm] enabled but ANTHROPIC_API_KEY is not set; \
120 skipping LLM rules for this run."
121 );
122 Arc::new(NullLlmClient)
123 }
124 }
125 }
126 LlmProvider::OpenAi | LlmProvider::Local => {
127 match OpenAiLlmClient::new(cfg.provider, cfg.model.clone(), cfg.timeout_s) {
128 Some(c) => Arc::new(c),
129 None => {
130 if cfg.provider == LlmProvider::OpenAi {
131 eprintln!(
132 "spec-drift: [llm] enabled with openai provider but OPENAI_API_KEY is not set; \
133 skipping LLM rules for this run."
134 );
135 } else {
136 eprintln!(
137 "spec-drift: [llm] enabled with local provider but creation failed; \
138 skipping LLM rules for this run."
139 );
140 }
141 Arc::new(NullLlmClient)
142 }
143 }
144 }
145 };
146
147 Arc::new(BudgetedClient::new(inner, cfg.max_calls))
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use std::sync::atomic::{AtomicU32, Ordering};
154
155 struct CountingClient {
156 calls: AtomicU32,
157 }
158
159 impl LlmClient for CountingClient {
160 fn evaluate(&self, _: &str, _: &str) -> Option<LlmVerdict> {
161 self.calls.fetch_add(1, Ordering::AcqRel);
162 Some(LlmVerdict {
163 match_spec: true,
164 reason: "ok".into(),
165 })
166 }
167
168 fn complete(&self, _: &str, _: &str) -> Option<String> {
169 self.calls.fetch_add(1, Ordering::AcqRel);
170 Some("ok".into())
171 }
172 }
173
174 #[test]
175 fn budget_exhausts_and_stops_calling_inner() {
176 let inner = Arc::new(CountingClient {
177 calls: AtomicU32::new(0),
178 });
179 let client = BudgetedClient::new(inner.clone(), 2);
180
181 assert!(client.evaluate("s", "u").is_some());
182 assert!(client.evaluate("s", "u").is_some());
183 assert!(client.evaluate("s", "u").is_none());
184 assert!(client.evaluate("s", "u").is_none());
185
186 assert_eq!(inner.calls.load(Ordering::Acquire), 2);
187 }
188
189 #[test]
190 fn force_off_always_yields_null_client() {
191 let cfg = LlmConfig {
192 enabled: true,
193 ..LlmConfig::default()
194 };
195 let client = build_client(&cfg, true);
196 assert!(client.evaluate("s", "u").is_none());
197 }
198
199 #[test]
200 fn disabled_config_yields_null_client() {
201 let cfg = LlmConfig::default(); let client = build_client(&cfg, false);
203 assert!(client.evaluate("s", "u").is_none());
204 }
205}