Skip to main content

recon_cli/script/bindings/ai/
backend.rs

1//! Backend trait + dispatcher.
2
3use std::time::Duration;
4use std::collections::HashMap;
5
6use crate::config::AiConfig;
7use super::request::Request;
8
9/// Per-call context passed to each `AiBackend::invoke`. Holds the
10/// effective config and a verbosity level for logging.
11pub struct BackendCtx<'a> {
12    pub config: &'a AiConfig,
13    pub effective_model: Option<String>,
14    pub effective_timeout: Duration,
15    pub verbose: u8,
16}
17
18/// Successful backend response.
19#[derive(Debug, Clone)]
20pub struct Response {
21    pub text: String,
22    pub backend: String,
23    pub model: Option<String>,
24    pub duration: Duration,
25    pub exit_code: i32,
26    /// Total characters sent on the conceptual payload (body + system).
27    /// Populated by each backend from its `FlatPayload`; surfaced only in
28    /// the `-v` `.send()` telemetry line, not in the `send_full()` map.
29    pub chars_in: usize,
30}
31
32/// A backend dispatches a `Request` to an underlying CLI / API and
33/// returns a `Response`. Implementations live in `backends/<name>.rs`.
34pub trait AiBackend: Send + Sync {
35    fn name(&self) -> &'static str;
36    fn invoke(&self, req: &Request, ctx: &BackendCtx<'_>) -> Result<Response, String>;
37}
38
39/// Registry of available backends. Built-in backends are registered
40/// at engine startup; the `cmd` backend is materialized on demand
41/// from `[ai.backends.<name>]` config entries.
42pub struct Registry {
43    built_ins: HashMap<&'static str, Box<dyn AiBackend>>,
44}
45
46impl Registry {
47    /// Returns an empty registry. Built-in backends are added by
48    /// `with_built_ins` in later tasks.
49    pub fn empty() -> Self {
50        Self { built_ins: HashMap::new() }
51    }
52
53    pub fn register(&mut self, backend: Box<dyn AiBackend>) {
54        self.built_ins.insert(backend.name(), backend);
55    }
56
57    pub fn get(&self, name: &str) -> Option<&dyn AiBackend> {
58        self.built_ins.get(name).map(|b| b.as_ref())
59    }
60
61    pub fn has(&self, name: &str) -> bool {
62        self.built_ins.contains_key(name)
63    }
64}
65
66/// Resolves the named backend (built-in or config-defined `cmd`) and
67/// invokes it. Returns the `Response` or a tagged error string.
68pub fn dispatch(
69    backend_name: &str,
70    req: &Request,
71    config: &AiConfig,
72    ctx: &BackendCtx<'_>,
73    registry: &Registry,
74) -> Result<Response, String> {
75    if let Some(b) = registry.get(backend_name) {
76        return b.invoke(req, ctx);
77    }
78    // Fall through to config-defined cmd entries.
79    if let Some(bcfg) = config.backends.get(backend_name) {
80        if !bcfg.cmd.is_empty() {
81            return super::backends::cmd::invoke(backend_name, bcfg, req, ctx);
82        }
83    }
84    Err(format!(
85        "ai: backend '{backend_name}' not found (no built-in, and no \
86         `[ai.backends.{backend_name}]` with non-empty `cmd` in ~/.recon/config.toml)"
87    ))
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    struct FakeBackend;
95    impl AiBackend for FakeBackend {
96        fn name(&self) -> &'static str { "fake" }
97        fn invoke(&self, _req: &Request, _ctx: &BackendCtx<'_>) -> Result<Response, String> {
98            Ok(Response {
99                text: "ok".into(),
100                backend: "fake".into(),
101                model: None,
102                duration: Duration::from_millis(1),
103                exit_code: 0,
104                chars_in: 0,
105            })
106        }
107    }
108
109    #[test]
110    fn registry_round_trip() {
111        let mut reg = Registry::empty();
112        reg.register(Box::new(FakeBackend));
113        assert!(reg.has("fake"));
114        assert!(!reg.has("missing"));
115        let b = reg.get("fake").expect("present");
116        assert_eq!(b.name(), "fake");
117    }
118}