Skip to main content

spec_drift/llm/
mod.rs

1//! LLM-backed rule support.
2//!
3//! Everything in this module is strictly opt-in: the default [`Config::llm`]
4//! has `enabled = false`, and `--no-llm` on the CLI forces the null client
5//! regardless of config. Analyzers that consume an [`LlmClient`] receive a
6//! [`NullLlmClient`] in every default code path.
7//!
8//! # Design
9//!
10//! - The trait returns `Option<LlmVerdict>`. `None` is the universal
11//!   "no answer available" signal — used for: LLM disabled, budget exhausted,
12//!   network error, timeout, malformed response. Analyzers must treat `None`
13//!   as "skip silently, don't emit drift" because the rule is experimental.
14//! - The budget is enforced via an atomic counter on the wrapper. Analyzers
15//!   can't accidentally overspend even under `rayon` parallelism.
16
17pub 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/// Verdict an LLM returns when asked whether a spec claim still matches code.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct LlmVerdict {
32    /// True when the spec text still accurately describes the code.
33    pub match_spec: bool,
34    /// Short human-readable explanation — shown in the divergence report.
35    pub reason: String,
36}
37
38/// Minimum surface every LLM provider must implement.
39pub trait LlmClient: Send + Sync {
40    /// Ask the model whether `user_prompt` holds given the project context in
41    /// `system_prompt`. Returns `None` on any failure mode — analyzers treat
42    /// `None` as "skip, don't flag."
43    fn evaluate(&self, system_prompt: &str, user_prompt: &str) -> Option<LlmVerdict>;
44
45    /// Complete raw text from the model given `system_prompt` and `user_prompt`.
46    /// Returns `None` on any failure mode.
47    fn complete(&self, system_prompt: &str, user_prompt: &str) -> Option<String>;
48}
49
50/// Budget + kill-switch wrapper. Every real LLM call flows through here.
51pub 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        // Atomic fetch-decrement with saturating underflow guard.
68        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
102/// Build the LLM client stack described by `cfg`. `force_off=true` mirrors
103/// `--no-llm` and always yields a null client, regardless of config.
104///
105/// Providers other than `Anthropic` are not implemented yet — they fall back
106/// to the null client with a warning so config authors can migrate once the
107/// adapters ship, without breaking existing runs.
108pub 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(); // enabled=false
202        let client = build_client(&cfg, false);
203        assert!(client.evaluate("s", "u").is_none());
204    }
205}