1pub 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#[derive(Debug, Clone)]
53pub struct StubProvider {
54 model: String,
55}
56
57impl StubProvider {
58 #[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
130pub 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#[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}