Skip to main content

bock_ai/
lib.rs

1//! AI provider interface for the Bock transpilation pipeline (§17.8).
2//!
3//! This crate defines the [`AiProvider`] trait and its four interaction
4//! modes — [`generate`](AiProvider::generate) (Tier 1),
5//! [`repair`](AiProvider::repair) (§17.7 feedback loop),
6//! [`optimize`](AiProvider::optimize) (Tier 3), and
7//! [`select`](AiProvider::select) (§10.8 adaptive handler strategy
8//! selection). Verification (§17.3) is deterministic and is **not**
9//! part of this trait; it is owned by the target profile and
10//! `bock-codegen`.
11//!
12//! The crate ships a [`StubProvider`] for tests and a [`make_provider`]
13//! factory that dispatches on [`AiConfig::provider`]. Both HTTP-backed
14//! providers — [`OpenAiCompatProvider`] (`"openai-compatible"`) and
15//! [`AnthropicProvider`] (`"anthropic"`) — are linked.
16
17pub mod cache;
18pub mod caching_provider;
19pub mod config;
20pub mod decision;
21pub mod error;
22pub mod governance;
23pub mod manifest;
24pub mod provider;
25pub mod providers;
26pub mod request;
27pub mod rules;
28
29pub use cache::{compute_key, AiCache, CacheError, CacheStats};
30pub use caching_provider::CachingProvider;
31pub use config::AiConfig;
32pub use decision::{Decision, DecisionType, ManifestScope};
33pub use error::AiError;
34pub use governance::{validate_production, StrictnessPolicy, UnpinnedEntry, UnpinnedReport};
35pub use manifest::{ManifestError, ManifestWriter};
36pub use provider::{validate_select_response, AiProvider};
37pub use providers::{AnthropicProvider, OpenAiCompatProvider};
38pub use request::{
39    Alternative, CandidateRule, DecisionRef, GenerateRequest, GenerateResponse, ModuleContext,
40    OptimizationHint, OptimizeRequest, OptimizeResponse, RepairRequest, RepairResponse,
41    SelectContext, SelectOption, SelectRequest, SelectResponse, TargetProfile,
42};
43pub use rules::{compute_rule_id, node_kind_name, Provenance, Rule, RuleCache, RuleCacheError};
44
45use async_trait::async_trait;
46
47/// Deterministic test provider that returns canned responses for all
48/// four interaction modes.
49///
50/// Used in unit tests, and as the default selection when
51/// [`AiConfig::provider`] is `"stub"`. Never performs network I/O.
52#[derive(Debug, Clone)]
53pub struct StubProvider {
54    model: String,
55}
56
57impl StubProvider {
58    /// Creates a stub provider whose [`model_id`](AiProvider::model_id)
59    /// is derived from `config.model` (defaulting to `"stub"` when empty).
60    #[must_use]
61    pub fn new(config: AiConfig) -> Self {
62        let model = if config.model.is_empty() {
63            "stub".into()
64        } else {
65            config.model
66        };
67        Self { model }
68    }
69}
70
71impl Default for StubProvider {
72    fn default() -> Self {
73        Self::new(AiConfig::default())
74    }
75}
76
77#[async_trait]
78impl AiProvider for StubProvider {
79    async fn generate(
80        &self,
81        request: &GenerateRequest,
82    ) -> Result<GenerateResponse, AiError> {
83        Ok(GenerateResponse {
84            code: format!("// stub generate for target '{}'\n", request.target.id),
85            confidence: 1.0,
86            reasoning: Some("stub: deterministic canned response".into()),
87            alternatives: Vec::new(),
88        })
89    }
90
91    async fn repair(&self, request: &RepairRequest) -> Result<RepairResponse, AiError> {
92        Ok(RepairResponse {
93            fixed_code: request.original_code.clone(),
94            confidence: 1.0,
95            candidate_rule: None,
96            reasoning: Some("stub: echo original code".into()),
97        })
98    }
99
100    async fn optimize(
101        &self,
102        request: &OptimizeRequest,
103    ) -> Result<OptimizeResponse, AiError> {
104        Ok(OptimizeResponse {
105            optimized_code: request.working_code.clone(),
106            confidence: 1.0,
107            improvements: Vec::new(),
108            reasoning: Some("stub: no-op optimization".into()),
109        })
110    }
111
112    async fn select(&self, request: &SelectRequest) -> Result<SelectResponse, AiError> {
113        let first = request.options.first().ok_or_else(|| {
114            AiError::InvalidResponse("stub select: empty option set".into())
115        })?;
116        let response = SelectResponse {
117            selected_id: first.id.clone(),
118            confidence: 1.0,
119            reasoning: Some("stub: first option".into()),
120        };
121        validate_select_response(&request.options, &response)?;
122        Ok(response)
123    }
124
125    fn model_id(&self) -> String {
126        format!("stub:{}", self.model)
127    }
128}
129
130/// Constructs a provider from an [`AiConfig`].
131///
132/// Dispatches on [`AiConfig::provider`] via a static `match` — per Q7
133/// of the 2026-04-20 spec amendment, no plugin loading. Both HTTP-backed
134/// providers are linked: `"openai-compatible"` (D.3) and `"anthropic"`
135/// (D.4). `"stub"` is always available for tests.
136///
137/// # Errors
138/// Returns [`AiError::ProviderError`] when `config.provider` is not a
139/// recognized identifier, [`AiError::Auth`] when a provider cannot load
140/// its API key, or [`AiError::Unavailable`] when a provider is recognized
141/// but not yet linked.
142pub fn make_provider(config: AiConfig) -> Result<Box<dyn AiProvider>, AiError> {
143    match config.provider.as_str() {
144        "stub" => Ok(Box::new(StubProvider::new(config))),
145        "openai-compatible" => Ok(Box::new(OpenAiCompatProvider::new(config)?)),
146        "anthropic" => Ok(Box::new(AnthropicProvider::new(config)?)),
147        other => Err(AiError::ProviderError(format!(
148            "unknown provider: {other}"
149        ))),
150    }
151}
152
153/// The list of built-in provider identifiers accepted by [`make_provider`].
154///
155/// Intended for tooling (vocab emitter, CLI help rendering). The order
156/// here is the preferred listing order.
157#[must_use]
158pub fn known_providers() -> &'static [&'static str] {
159    &["openai-compatible", "anthropic", "stub"]
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use bock_air::{AIRNode, NodeIdGen, NodeKind};
166    use bock_errors::Span;
167    use bock_types::Strictness;
168    use std::collections::HashMap;
169
170    fn dummy_node() -> AIRNode {
171        let gen = NodeIdGen::new();
172        AIRNode::new(
173            gen.next(),
174            Span::dummy(),
175            NodeKind::Block {
176                stmts: Vec::new(),
177                tail: None,
178            },
179        )
180    }
181
182    fn dummy_target() -> TargetProfile {
183        TargetProfile {
184            id: "js".into(),
185            display_name: "JavaScript".into(),
186            capabilities: HashMap::new(),
187            conventions: HashMap::new(),
188        }
189    }
190
191    #[tokio::test]
192    async fn stub_generate_returns_canned() {
193        let p = StubProvider::default();
194        let req = GenerateRequest {
195            node: dummy_node(),
196            target: dummy_target(),
197            module_context: ModuleContext::default(),
198            prior_decisions: Vec::new(),
199            strictness: Strictness::Development,
200        };
201        let resp = p.generate(&req).await.expect("ok");
202        assert!(resp.code.contains("js"));
203        assert!((resp.confidence - 1.0).abs() < f64::EPSILON);
204    }
205
206    #[tokio::test]
207    async fn stub_repair_echoes_original() {
208        let p = StubProvider::default();
209        let req = RepairRequest {
210            original_code: "let x = 1;".into(),
211            compiler_error: "oops".into(),
212            node: dummy_node(),
213            target: dummy_target(),
214        };
215        let resp = p.repair(&req).await.expect("ok");
216        assert_eq!(resp.fixed_code, "let x = 1;");
217    }
218
219    #[tokio::test]
220    async fn stub_optimize_is_identity() {
221        let p = StubProvider::default();
222        let req = OptimizeRequest {
223            working_code: "return 1;".into(),
224            node: dummy_node(),
225            target: dummy_target(),
226            optimization_hints: vec![OptimizationHint::Performance],
227        };
228        let resp = p.optimize(&req).await.expect("ok");
229        assert_eq!(resp.optimized_code, "return 1;");
230    }
231
232    #[tokio::test]
233    async fn stub_select_returns_first_option() {
234        let p = StubProvider::default();
235        let req = SelectRequest {
236            options: vec![
237                SelectOption {
238                    id: "a".into(),
239                    description: "first".into(),
240                },
241                SelectOption {
242                    id: "b".into(),
243                    description: "second".into(),
244                },
245            ],
246            context: SelectContext::default(),
247            rationale_prompt: "pick one".into(),
248        };
249        let resp = p.select(&req).await.expect("ok");
250        assert_eq!(resp.selected_id, "a");
251    }
252
253    #[tokio::test]
254    async fn stub_select_fails_on_empty_options() {
255        let p = StubProvider::default();
256        let req = SelectRequest {
257            options: Vec::new(),
258            context: SelectContext::default(),
259            rationale_prompt: "pick one".into(),
260        };
261        let err = p.select(&req).await.expect_err("should fail");
262        assert!(matches!(err, AiError::InvalidResponse(_)));
263    }
264
265    #[test]
266    fn stub_model_id_format() {
267        let p = StubProvider::default();
268        assert_eq!(p.model_id(), "stub:stub");
269
270        let p2 = StubProvider::new(AiConfig {
271            model: "custom".into(),
272            ..AiConfig::default()
273        });
274        assert_eq!(p2.model_id(), "stub:custom");
275    }
276
277    #[test]
278    fn factory_dispatches_to_stub() {
279        let cfg = AiConfig {
280            provider: "stub".into(),
281            ..AiConfig::default()
282        };
283        let p = make_provider(cfg).expect("stub constructs");
284        assert!(p.model_id().starts_with("stub:"));
285    }
286
287    #[test]
288    fn factory_dispatches_to_openai_compatible() {
289        let cfg = AiConfig {
290            provider: "openai-compatible".into(),
291            endpoint: "http://localhost:11434/v1".into(),
292            model: "llama3".into(),
293            ..AiConfig::default()
294        };
295        let p = make_provider(cfg).expect("openai-compatible constructs");
296        assert_eq!(p.model_id(), "openai-compatible:llama3");
297    }
298
299    #[test]
300    fn factory_openai_compatible_requires_key_for_remote() {
301        let cfg = AiConfig {
302            provider: "openai-compatible".into(),
303            endpoint: "https://api.example.com/v1".into(),
304            model: "gpt-4o".into(),
305            api_key_env: Some("__BOCK_AI_TEST_UNSET_ENV_VAR__".into()),
306            ..AiConfig::default()
307        };
308        let err = make_provider(cfg).err().expect("missing key");
309        assert!(matches!(err, AiError::Auth(_)));
310    }
311
312    #[test]
313    fn factory_dispatches_to_anthropic() {
314        std::env::set_var("__BOCK_AI_FACTORY_ANTHROPIC_KEY__", "sk-ant-fake");
315        let cfg = AiConfig {
316            provider: "anthropic".into(),
317            endpoint: "https://api.anthropic.com/v1".into(),
318            model: "claude-opus-4-7".into(),
319            api_key_env: Some("__BOCK_AI_FACTORY_ANTHROPIC_KEY__".into()),
320            ..AiConfig::default()
321        };
322        let p = make_provider(cfg).expect("anthropic constructs");
323        assert_eq!(p.model_id(), "anthropic:claude-opus-4-7");
324        std::env::remove_var("__BOCK_AI_FACTORY_ANTHROPIC_KEY__");
325    }
326
327    #[test]
328    fn factory_anthropic_requires_key() {
329        let cfg = AiConfig {
330            provider: "anthropic".into(),
331            endpoint: "https://api.anthropic.com/v1".into(),
332            model: "claude-opus-4-7".into(),
333            api_key_env: Some("__BOCK_AI_ANTHROPIC_UNSET_ENV_VAR__".into()),
334            ..AiConfig::default()
335        };
336        let err = make_provider(cfg).err().expect("missing key");
337        assert!(matches!(err, AiError::Auth(_)));
338    }
339
340    #[test]
341    fn factory_rejects_unknown_provider() {
342        let cfg = AiConfig {
343            provider: "made-up".into(),
344            ..AiConfig::default()
345        };
346        let err = make_provider(cfg).err().expect("unknown");
347        match err {
348            AiError::ProviderError(m) => assert!(m.contains("made-up")),
349            other => panic!("expected ProviderError, got {other:?}"),
350        }
351    }
352}