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}