Skip to main content

el_ffi/
lib.rs

1//! `el-ffi` — host bindings for the Rust core (ADR-001, ADR-009, ADR-010).
2//!
3//! One Rust API surface exported three ways:
4//!
5//! ## React Native — `uniffi-bindgen-react-native` (ADR-001)
6//! `#[derive(uniffi::Object)]` + `#[uniffi::export]` → TypeScript + JSI C++ +
7//! Turbo Module. Streaming via `StreamHandler` callback interface (UniFFI
8//! cannot export `impl FnMut` parameters).
9//!
10//! ## Dart / pub.dev — `flutter_rust_bridge` v2 codegen (ADR-024)
11//! `#[frb(opaque)]` on `EdgeLlm` → Dart opaque handle. `ask()` →
12//! `Future<String>`, `edge_llm_ask_stream()` + `StreamSink<String>` →
13//! `Stream<String>`.
14//!
15//! ## Web / npm — `wasm-bindgen` (ADR-001)
16//! `#[wasm_bindgen]` on both the struct **and** the impl block → ESM TypeScript
17//! package via `wasm-pack`. The struct annotation is required: without it
18//! wasm-bindgen cannot satisfy `IntoWasmAbi`/`WasmDescribe` for the impl block.
19//!
20//! **Web limitations**: the local path uses a dev-stage echo placeholder until
21//! Candle-on-wasm is wired, and the **cloud backend is not available on web**
22//! (ADR-010 amendment): `el-cloud`'s blocking HTTP transport has no wasm
23//! implementation, so `EdgeLlm.cloud` throws an explicit error there instead
24//! of silently degrading.
25
26// `#![forbid(unsafe_code)]` cannot be used: `forbid` is unoverridable even by
27// inner `#[allow]`, so `frb_generated` (generated FFI glue) would not compile.
28// `deny` permits the scoped override below. Invariant: the only permitted use
29// of `#[allow(unsafe_code)]` in this crate is on `mod frb_generated`.
30#![deny(unsafe_code)]
31
32#[cfg(not(target_arch = "wasm32"))]
33use el_core::CredentialRef;
34use el_core::{ChatMessage, ChatRequest, ChatToken, LlmProvider};
35
36// UniFFI scaffolding — must appear once per crate, before any uniffi proc-macros.
37#[cfg(not(target_arch = "wasm32"))]
38uniffi::setup_scaffolding!("el_ffi");
39
40#[cfg(not(target_arch = "wasm32"))]
41use flutter_rust_bridge::for_generated::DcoCodec;
42#[cfg(not(target_arch = "wasm32"))]
43use flutter_rust_bridge::frb;
44
45#[cfg(not(target_arch = "wasm32"))]
46#[allow(unsafe_code)]
47mod frb_generated;
48#[cfg(not(target_arch = "wasm32"))]
49use frb_generated::StreamSink;
50
51#[cfg(target_arch = "wasm32")]
52use wasm_bindgen::prelude::*;
53
54// ── Error type ────────────────────────────────────────────────────────────────
55
56/// Error returned across the FFI boundary.
57///
58/// On **native** (non-wasm32): `#[uniffi::Error]` projects this to the host
59/// language's exception type (TS `Error`, Kotlin `Exception`, Swift `Error`).
60/// On **wasm32**: converted to a JS exception via `JsValue` at the
61/// `ask_wasm` call site.
62///
63/// Design note: `EdgeError` from el-core is not directly FFI-safe (uses
64/// `Box<str>` and Rust-specific variants). `SdkError` is a thin projection.
65#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Error))]
66#[derive(Debug)]
67pub enum SdkError {
68    /// The LLM backend (local Candle or cloud) returned an error.
69    ProviderError { message: String },
70}
71
72impl std::fmt::Display for SdkError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        let Self::ProviderError { message } = self;
75        write!(f, "{message}")
76    }
77}
78
79impl From<el_core::EdgeError> for SdkError {
80    fn from(e: el_core::EdgeError) -> Self {
81        Self::ProviderError {
82            message: e.to_string(),
83        }
84    }
85}
86
87fn emit_stream_error(
88    result: std::result::Result<(), SdkError>,
89    sink_closed: bool,
90    mut emit: impl FnMut(String),
91) {
92    if let Err(error) = result {
93        if !sink_closed {
94            emit(error.to_string());
95        }
96    }
97}
98
99// ── Streaming callback interface (UniFFI / React Native) ─────────────────────
100
101/// Token-by-token callback for streaming on React Native.
102///
103/// Implement on the TS/Kotlin/Swift side and pass to
104/// [`EdgeLlm::ask_stream_cb`]. Each call delivers one text fragment; the
105/// method returns (and calls nothing more) when generation is complete.
106///
107/// Dart bindings use the `edge_llm_ask_stream` FRB wrapper and
108/// `StreamSink<String>` instead.
109#[cfg(not(target_arch = "wasm32"))]
110#[uniffi::export(callback_interface)]
111pub trait StreamHandler: Send + Sync {
112    fn on_token(&self, token: String);
113}
114
115// ── Public FFI facade ────────────────────────────────────────────────────────
116
117/// The flat FFI-friendly facade (ADR-001, ADR-009, ADR-010).
118///
119/// Annotated for all three binding surfaces:
120/// - `uniffi::Object` (native) → opaque UniFFI / React Native handle
121/// - `frb(opaque)` (native) → opaque Dart handle via FRB v2 codegen
122/// - `wasm_bindgen` (wasm32) → satisfies `IntoWasmAbi`/`WasmDescribe` so
123///   that `#[wasm_bindgen] impl EdgeLlm { ... }` compiles
124#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Object))]
125#[cfg_attr(not(target_arch = "wasm32"), frb(opaque))]
126#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
127pub struct EdgeLlm {
128    provider: Box<dyn LlmProvider>,
129    /// Default model routing string (stored so `ask()` can fill `ChatRequest::model`).
130    default_model: String,
131}
132
133/// UniFFI-exported methods: constructors, blocking chat, and reset.
134///
135/// Dart uses the `edge_llm_*` free-function wrappers below so FRB does not
136/// need to parse the UniFFI-decorated impl block after macro expansion.
137#[cfg_attr(not(target_arch = "wasm32"), uniffi::export)]
138impl EdgeLlm {
139    /// Construct with the local Candle engine (air-gapped, ADR-002/004).
140    ///
141    /// If `model_uri` is non-empty, loads the GGUF at that path via
142    /// `CandleEngine::from_path` (consumer-supplied model, ADR-002).
143    /// Pass an empty string to use a deterministic toy model for development
144    /// and testing — the toy generates gibberish but exercises the full
145    /// binding layer end-to-end.
146    ///
147    /// Returns `Err(SdkError)` if `model_uri` is non-empty but the file
148    /// cannot be parsed (missing, malformed GGUF, incompatible tensor shapes).
149    /// An empty `model_uri` never fails.
150    ///
151    /// The permissive signature verifier used here is intentional: it lets the
152    /// binding layer be exercised without a signed model artifact. A production
153    /// deployment should substitute a real `SignatureVerifier` backed by the
154    /// platform keystore.
155    #[cfg_attr(not(target_arch = "wasm32"), uniffi::constructor)]
156    pub fn local(model_uri: String) -> Result<Self, SdkError> {
157        #[cfg(not(target_arch = "wasm32"))]
158        {
159            use el_core::{ModelFormat, ModelId, ModelVersion};
160            use el_provenance::{ModelArtifact, SignatureVerifier};
161
162            struct PermissiveVerifier;
163            impl SignatureVerifier for PermissiveVerifier {
164                fn verify(&self, _: &[u8], _: &[u8], _: u32) -> bool {
165                    true
166                }
167            }
168
169            let mut art =
170                ModelArtifact::new(ModelId(1), ModelVersion::new(0, 1, 0), ModelFormat::Gguf);
171            art.verify(&PermissiveVerifier, b"placeholder", b"sig", 0);
172            let permit = art.ensure_loadable().map_err(SdkError::from)?;
173
174            let provider: Box<dyn LlmProvider> = if model_uri.is_empty() {
175                // No path — toy model for development/tests.
176                Box::new(
177                    el_engine_candle::LocalLlmProvider::toy(256, 64, 255, permit)
178                        .map_err(SdkError::from)?,
179                )
180            } else {
181                // Consumer-supplied GGUF path.
182                Box::new(
183                    el_engine_candle::LocalLlmProvider::from_path(&model_uri, 1, permit)
184                        .map_err(SdkError::from)?,
185                )
186            };
187
188            Ok(Self {
189                provider,
190                default_model: "local".into(),
191            })
192        }
193        #[cfg(target_arch = "wasm32")]
194        Ok(Self {
195            provider: Box::new(EchoProvider),
196            default_model: "local".into(),
197        })
198    }
199
200    /// Construct with a frontier cloud backend (opt-in, ADR-010).
201    ///
202    /// `model` uses the routing prefix: `"openai/gpt-4o"`,
203    /// `"anthropic/claude-sonnet-4-6"`, `"ollama/llama3"`,
204    /// `"gemini/gemini-2.0-flash"`, or any OpenAI-compat base URL.
205    /// `api_key` must come from the platform keystore — never embedded.
206    ///
207    /// **Native only** (React Native / Dart native). On wasm32 this constructor
208    /// does not exist — the web surface exposes a throwing `cloud` instead
209    /// (see the wasm32 impl block below and the ADR-010 amendment).
210    #[cfg(not(target_arch = "wasm32"))]
211    #[uniffi::constructor]
212    pub fn cloud(model: String, api_key: String) -> Self {
213        let credential = CredentialRef::new(api_key);
214        let inner = el_cloud::CloudProvider::new();
215        let provider = BoundCloudProvider {
216            model: model.clone(),
217            credential,
218            inner,
219        };
220        Self {
221            provider: Box::new(provider),
222            default_model: model,
223        }
224    }
225
226    /// Blocking chat completion.
227    ///
228    /// Returns `Err(SdkError::ProviderError)` on network/auth/engine failure
229    /// so callers can distinguish model output from error conditions.
230    pub fn ask(&self, prompt: String) -> Result<String, SdkError> {
231        let req = ChatRequest::new(self.default_model.clone(), vec![ChatMessage::user(prompt)]);
232        self.provider
233            .chat(&req)
234            .map(|r| r.content)
235            .map_err(SdkError::from)
236    }
237
238    /// Reset the session (clears KV cache and output).
239    pub fn reset(&self) {
240        // Reset happens automatically at the start of each LocalLlmProvider::chat() call.
241    }
242}
243
244impl EdgeLlm {
245    fn ask_stream_with(
246        &self,
247        prompt: String,
248        mut on_token: impl FnMut(String),
249    ) -> Result<(), SdkError> {
250        let req = ChatRequest::new(self.default_model.clone(), vec![ChatMessage::user(prompt)]);
251        self.provider
252            .chat_stream(&req, &mut |t: ChatToken| {
253                if !t.is_final {
254                    on_token(t.text);
255                }
256            })
257            .map_err(SdkError::from)
258    }
259}
260
261/// Streaming via callback interface — exported for React Native (UniFFI).
262///
263/// Separated from the main block because UniFFI cannot export `impl FnMut`.
264#[cfg(not(target_arch = "wasm32"))]
265#[uniffi::export]
266impl EdgeLlm {
267    /// Stream tokens to a [`StreamHandler`] callback (React Native path).
268    ///
269    /// Returns an error on network/auth/engine failure so callers are not
270    /// left waiting for a stream that will never arrive.
271    pub fn ask_stream_cb(
272        &self,
273        prompt: String,
274        handler: Box<dyn StreamHandler>,
275    ) -> Result<(), SdkError> {
276        self.ask_stream_with(prompt, |token| handler.on_token(token))
277    }
278}
279
280/// Dart / FRB wrappers.
281///
282/// These are intentionally separate from the UniFFI impl blocks. FRB parses
283/// these plain Rust functions and the Dart facade wraps them into the public
284/// `EdgeLlm` class API.
285#[cfg(not(target_arch = "wasm32"))]
286pub mod dart_api {
287    use super::*;
288
289    #[frb]
290    pub fn edge_llm_local(model_uri: String) -> anyhow::Result<EdgeLlm> {
291        EdgeLlm::local(model_uri).map_err(to_anyhow)
292    }
293
294    #[frb]
295    pub fn edge_llm_cloud(model: String, api_key: String) -> EdgeLlm {
296        EdgeLlm::cloud(model, api_key)
297    }
298
299    #[frb]
300    pub fn edge_llm_ask(sdk: &EdgeLlm, prompt: String) -> anyhow::Result<String> {
301        sdk.ask(prompt).map_err(to_anyhow)
302    }
303
304    #[frb]
305    pub fn edge_llm_reset(sdk: &EdgeLlm) {
306        sdk.reset();
307    }
308
309    #[frb]
310    pub fn edge_llm_ask_stream(sdk: &EdgeLlm, prompt: String, sink: StreamSink<String, DcoCodec>) {
311        let mut sink_closed = false;
312        let result = sdk.ask_stream_with(prompt, |token| {
313            if !sink_closed {
314                if sink.add(token).is_err() {
315                    // Dart cancelled the stream (e.g. take(n), listen().cancel()).
316                    // LlmProvider has no cancellation hook so generation runs to
317                    // completion; remaining tokens are silently dropped.
318                    sink_closed = true;
319                }
320            }
321        });
322
323        // The returned Dart Stream is the consumer-facing error channel.
324        // Completing the generated task successfully prevents its unawaited
325        // future from reporting a duplicate global error.
326        emit_stream_error(result, sink_closed, |message| {
327            let _ = sink.add_error(message);
328        });
329    }
330
331    // Converts SdkError to an anyhow string error for FRB's Dart propagation.
332    // FRB surfaces this as a Dart AnyhowException(message) — variant type is
333    // erased. If SdkError grows structured variants (e.g. AuthError { code }),
334    // replace this with a #[frb]-annotated error enum in dart_api and return
335    // Result<_, DartError> directly instead of going through anyhow.
336    fn to_anyhow(error: SdkError) -> anyhow::Error {
337        anyhow::anyhow!(error.to_string())
338    }
339}
340
341// ── wasm32 surface ────────────────────────────────────────────────────────────
342
343/// wasm-bindgen methods. `ask_wasm` converts `SdkError` to a JS exception
344/// (`Result<_, JsValue>`) so the npm consumer can use `try/catch`.
345#[cfg(target_arch = "wasm32")]
346#[wasm_bindgen]
347impl EdgeLlm {
348    #[wasm_bindgen(constructor)]
349    pub fn new_local(model_uri: String) -> Result<EdgeLlm, JsValue> {
350        EdgeLlm::local(model_uri).map_err(|e| JsValue::from_str(&e.to_string()))
351    }
352
353    /// Blocking chat; throws a JS Error on provider failure.
354    #[wasm_bindgen]
355    pub fn ask_wasm(&self, prompt: String) -> Result<String, JsValue> {
356        self.ask(prompt)
357            .map_err(|e| JsValue::from_str(&e.to_string()))
358    }
359
360    /// Frontier cloud backend is **not yet available on web** (ADR-010):
361    /// `el-cloud`'s blocking HTTP transport has no wasm implementation, and
362    /// the synchronous `LlmProvider` trait cannot await the browser's async
363    /// `fetch`. Always throws so callers fail loudly instead of silently
364    /// receiving an echo stub. Use a native binding (React Native / Dart native)
365    /// for cloud access.
366    #[wasm_bindgen]
367    pub fn cloud(_model: String, _api_key: String) -> Result<EdgeLlm, JsValue> {
368        Err(JsValue::from_str(
369            "EdgeLlm.cloud is not available on web/wasm: the cloud transport \
370             requires a native binding (ADR-010)",
371        ))
372    }
373}
374
375// ── Native-only helper types ──────────────────────────────────────────────────
376
377/// Wraps `CloudProvider` with a pinned model prefix and credential so that
378/// `EdgeLlm::ask()` — which only takes a prompt — can fill `ChatRequest` fully.
379#[cfg(not(target_arch = "wasm32"))]
380struct BoundCloudProvider {
381    model: String,
382    credential: CredentialRef,
383    inner: el_cloud::CloudProvider,
384}
385
386#[cfg(not(target_arch = "wasm32"))]
387impl LlmProvider for BoundCloudProvider {
388    fn chat(&self, req: &ChatRequest) -> el_core::Result<el_core::ChatResponse> {
389        let mut r = req.clone();
390        r.model = self.model.clone();
391        r.credential = Some(self.credential.clone());
392        self.inner.chat(&r)
393    }
394
395    fn chat_stream(
396        &self,
397        req: &ChatRequest,
398        on_token: &mut dyn FnMut(ChatToken),
399    ) -> el_core::Result<()> {
400        let mut r = req.clone();
401        r.model = self.model.clone();
402        r.credential = Some(self.credential.clone());
403        self.inner.chat_stream(&r, on_token)
404    }
405}
406
407// ── WASM placeholder (no network, no Candle) ──────────────────────────────────
408
409/// Dev-stage stand-in used **only** by the wasm32 `local` path until
410/// Candle-on-wasm is wired. The cloud path never falls back to this — on
411/// wasm32 the `cloud` constructor throws instead (ADR-010).
412#[cfg(target_arch = "wasm32")]
413struct EchoProvider;
414
415#[cfg(target_arch = "wasm32")]
416impl LlmProvider for EchoProvider {
417    fn chat(&self, req: &ChatRequest) -> el_core::Result<el_core::ChatResponse> {
418        let echo = req
419            .messages
420            .last()
421            .map(|m| m.content.as_str())
422            .unwrap_or("")
423            .to_owned();
424        Ok(el_core::ChatResponse {
425            content: echo,
426            model: "echo".into(),
427            prompt_tokens: 0,
428            completion_tokens: 0,
429        })
430    }
431    fn chat_stream(
432        &self,
433        req: &ChatRequest,
434        on_token: &mut dyn FnMut(ChatToken),
435    ) -> el_core::Result<()> {
436        let text = req
437            .messages
438            .last()
439            .map(|m| m.content.as_str())
440            .unwrap_or("")
441            .to_owned();
442        for ch in text.chars() {
443            on_token(ChatToken {
444                text: ch.to_string(),
445                is_final: false,
446            });
447        }
448        on_token(ChatToken {
449            text: String::new(),
450            is_final: true,
451        });
452        Ok(())
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn local_toy_ask_returns_non_empty_response() {
462        let sdk = EdgeLlm::local("".into()).expect("toy model never fails");
463        let response = sdk
464            .ask("hello".into())
465            .expect("local toy model should not error");
466        assert!(!response.is_empty());
467    }
468
469    #[test]
470    fn stream_ends_with_final_and_has_content() {
471        let sdk = EdgeLlm::local("".into()).expect("toy model never fails");
472        let mut parts: Vec<String> = Vec::new();
473        sdk.ask_stream_with("hi".into(), |t| parts.push(t))
474            .expect("local toy model stream should not error");
475        assert!(!parts.is_empty());
476    }
477
478    #[test]
479    fn ask_error_is_distinguishable_from_content() {
480        let sdk = EdgeLlm::local("".into()).expect("toy model never fails");
481        let r = sdk.ask("ping".into());
482        assert!(
483            r.is_ok(),
484            "toy local provider must not error on a plain prompt"
485        );
486        assert!(
487            !r.unwrap().starts_with("error:"),
488            "response must not look like a swallowed error"
489        );
490    }
491
492    #[test]
493    fn active_dart_stream_receives_one_provider_error() {
494        let mut errors = Vec::new();
495
496        emit_stream_error(
497            Err(SdkError::ProviderError {
498                message: "stream interrupted".into(),
499            }),
500            false,
501            |error| errors.push(error),
502        );
503
504        assert_eq!(errors, vec!["stream interrupted"]);
505    }
506
507    #[test]
508    fn local_missing_gguf_path_returns_sdk_error() {
509        let r = EdgeLlm::local("/nonexistent/model.gguf".into());
510        assert!(
511            matches!(r, Err(SdkError::ProviderError { .. })),
512            "non-empty path that doesn't exist must return SdkError"
513        );
514    }
515}