1 //! Flutter Rust Bridge v2 API for the Onde Inference Dart SDK.
2 //!
3 //! This module declares mirror types (re-declared plain Rust structs/enums
4 //! without UniFFI derives) and an `OndeChatEngine` opaque wrapper so that
5 //! `flutter_rust_bridge_codegen generate` can produce clean Dart bindings.
6 //!
7 //! All `onde::inference` types are imported with an `Onde*` alias to avoid
8 //! name collisions with the bridge mirrors defined below.
9
10 use crate::frb_generated::StreamSink;
11 use flutter_rust_bridge::frb;
12 use onde::inference::{
13 ChatEngine, ChatMessage as OndeChatMessage, ChatRole as OndeChatRole,
14 EngineInfo as OndeEngineInfo, EngineStatus as OndeEngineStatus,
15 GgufModelConfig as OndeGgufModelConfig, InferenceError, InferenceResult as OndeInferenceResult,
16 SamplingConfig as OndeSamplingConfig, StreamChunk as OndeStreamChunk,
17 };
18
19 // ── Mirror: ChatRole ──────────────────────────────────────────────────────────
20
21 /// Role of a participant in a chat conversation.
22 #[derive(Debug, Clone, PartialEq, Eq)]
23 pub enum ChatRole {
24 System,
25 User,
26 Assistant,
27 }
28
29 impl From<OndeChatRole> for ChatRole {
30 fn from(r: OndeChatRole) -> Self {
31 match r {
32 OndeChatRole::System => ChatRole::System,
33 OndeChatRole::User => ChatRole::User,
34 OndeChatRole::Assistant => ChatRole::Assistant,
35 }
36 }
37 }
38
39 impl From<ChatRole> for OndeChatRole {
40 fn from(r: ChatRole) -> Self {
41 match r {
42 ChatRole::System => OndeChatRole::System,
43 ChatRole::User => OndeChatRole::User,
44 ChatRole::Assistant => OndeChatRole::Assistant,
45 }
46 }
47 }
48
49 // ── Mirror: ChatMessage ───────────────────────────────────────────────────────
50
51 /// A single message in a conversation.
52 #[derive(Debug, Clone)]
53 pub struct ChatMessage {
54 pub role: ChatRole,
55 pub content: String,
56 }
57
58 impl From<OndeChatMessage> for ChatMessage {
59 fn from(m: OndeChatMessage) -> Self {
60 Self {
61 role: m.role.into(),
62 content: m.content,
63 }
64 }
65 }
66
67 impl From<ChatMessage> for OndeChatMessage {
68 fn from(m: ChatMessage) -> Self {
69 Self {
70 role: m.role.into(),
71 content: m.content,
72 }
73 }
74 }
75
76 // ── Mirror: SamplingConfig ────────────────────────────────────────────────────
77
78 /// Sampling parameters for text generation. All fields are optional — `None`
79 /// means "use the engine default".
80 #[derive(Debug, Clone)]
81 pub struct SamplingConfig {
82 pub temperature: Option<f64>,
83 pub top_p: Option<f64>,
84 pub top_k: Option<u64>,
85 pub min_p: Option<f64>,
86 pub max_tokens: Option<u64>,
87 pub frequency_penalty: Option<f32>,
88 pub presence_penalty: Option<f32>,
89 }
90
91 impl From<OndeSamplingConfig> for SamplingConfig {
92 fn from(s: OndeSamplingConfig) -> Self {
93 Self {
94 temperature: s.temperature,
95 top_p: s.top_p,
96 top_k: s.top_k,
97 min_p: s.min_p,
98 max_tokens: s.max_tokens,
99 frequency_penalty: s.frequency_penalty,
100 presence_penalty: s.presence_penalty,
101 }
102 }
103 }
104
105 impl From<SamplingConfig> for OndeSamplingConfig {
106 fn from(s: SamplingConfig) -> Self {
107 Self {
108 temperature: s.temperature,
109 top_p: s.top_p,
110 top_k: s.top_k,
111 min_p: s.min_p,
112 max_tokens: s.max_tokens,
113 frequency_penalty: s.frequency_penalty,
114 presence_penalty: s.presence_penalty,
115 }
116 }
117 }
118
119 // ── Mirror: GgufModelConfig ───────────────────────────────────────────────────
120
121 /// Configuration for loading a pre-quantised GGUF model.
122 #[derive(Debug, Clone)]
123 pub struct GgufModelConfig {
124 /// HuggingFace repository ID, e.g. `"bartowski/Qwen2.5-1.5B-Instruct-GGUF"`.
125 pub model_id: String,
126 /// GGUF filename(s) within the repository.
127 pub files: Vec<String>,
128 /// Optional explicit tokenizer model ID (required on Android).
129 pub tok_model_id: Option<String>,
130 /// Human-friendly display name, e.g. `"Qwen 2.5 1.5B"`.
131 pub display_name: String,
132 /// Approximate memory footprint, e.g. `"~941 MB (GGUF Q4_K_M)"`.
133 pub approx_memory: String,
134 }
135
136 impl From<OndeGgufModelConfig> for GgufModelConfig {
137 fn from(c: OndeGgufModelConfig) -> Self {
138 Self {
139 model_id: c.model_id,
140 files: c.files,
141 tok_model_id: c.tok_model_id,
142 display_name: c.display_name,
143 approx_memory: c.approx_memory,
144 }
145 }
146 }
147
148 impl From<GgufModelConfig> for OndeGgufModelConfig {
149 fn from(c: GgufModelConfig) -> Self {
150 Self {
151 model_id: c.model_id,
152 files: c.files,
153 tok_model_id: c.tok_model_id,
154 display_name: c.display_name,
155 approx_memory: c.approx_memory,
156 }
157 }
158 }
159
160 // ── Mirror: InferenceResult ───────────────────────────────────────────────────
161
162 /// The result of a completed inference request.
163 #[derive(Debug, Clone)]
164 pub struct InferenceResult {
165 /// The generated assistant reply text.
166 pub text: String,
167 /// Wall-clock inference duration in seconds.
168 pub duration_secs: f64,
169 /// Human-readable duration string, e.g. `"4.5s"` or `"2m 3.1s"`.
170 pub duration_display: String,
171 /// Finish reason reported by the model, e.g. `"stop"` or `"length"`.
172 pub finish_reason: String,
173 }
174
175 impl From<OndeInferenceResult> for InferenceResult {
176 fn from(r: OndeInferenceResult) -> Self {
177 Self {
178 text: r.text,
179 duration_secs: r.duration_secs,
180 duration_display: r.duration_display,
181 finish_reason: r.finish_reason,
182 }
183 }
184 }
185
186 // ── Mirror: StreamChunk ───────────────────────────────────────────────────────
187
188 /// A single streaming token chunk emitted during inference.
189 #[derive(Debug, Clone)]
190 pub struct StreamChunk {
191 /// The token text delta (may be empty on the final chunk).
192 pub delta: String,
193 /// `true` when this is the last chunk in the stream.
194 pub done: bool,
195 /// Finish reason (set only on the final chunk).
196 pub finish_reason: Option<String>,
197 }
198
199 impl From<OndeStreamChunk> for StreamChunk {
200 fn from(c: OndeStreamChunk) -> Self {
201 Self {
202 delta: c.delta,
203 done: c.done,
204 finish_reason: c.finish_reason,
205 }
206 }
207 }
208
209 // ── Mirror: EngineStatus ──────────────────────────────────────────────────────
210
211 /// Lifecycle status of the inference engine.
212 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
213 pub enum EngineStatus {
214 Unloaded,
215 Loading,
216 Ready,
217 Generating,
218 Error,
219 }
220
221 impl From<OndeEngineStatus> for EngineStatus {
222 fn from(s: OndeEngineStatus) -> Self {
223 match s {
224 OndeEngineStatus::Unloaded => EngineStatus::Unloaded,
225 OndeEngineStatus::Loading => EngineStatus::Loading,
226 OndeEngineStatus::Ready => EngineStatus::Ready,
227 OndeEngineStatus::Generating => EngineStatus::Generating,
228 OndeEngineStatus::Error => EngineStatus::Error,
229 }
230 }
231 }
232
233 // ── Mirror: EngineInfo ────────────────────────────────────────────────────────
234
235 /// A point-in-time snapshot of the engine's state.
236 #[derive(Debug, Clone)]
237 pub struct EngineInfo {
238 pub status: EngineStatus,
239 /// Display name of the currently loaded model, if any.
240 pub model_name: Option<String>,
241 /// Approximate memory footprint of the loaded model, if any.
242 pub approx_memory: Option<String>,
243 /// Number of conversation turns in the current history.
244 pub history_length: u64,
245 }
246
247 impl From<OndeEngineInfo> for EngineInfo {
248 fn from(i: OndeEngineInfo) -> Self {
249 Self {
250 status: i.status.into(),
251 model_name: i.model_name,
252 approx_memory: i.approx_memory,
253 history_length: i.history_length,
254 }
255 }
256 }
257
258 // ── Error type ────────────────────────────────────────────────────────────────
259
260 /// Bridge error type mapping from [`onde::inference::InferenceError`].
261 #[derive(Debug, thiserror::Error)]
262 pub enum OndeError {
263 #[error("No model loaded — call load_gguf_model first")]
264 NoModelLoaded,
265
266 #[error("Model already loaded: {model_name}")]
267 AlreadyLoaded { model_name: String },
268
269 #[error("Failed to build model: {reason}")]
270 ModelBuild { reason: String },
271
272 #[error("Inference failed: {reason}")]
273 Inference { reason: String },
274
275 #[error("Operation was cancelled")]
276 Cancelled,
277
278 #[error("{reason}")]
279 Other { reason: String },
280 }
281
282 impl From<InferenceError> for OndeError {
283 fn from(e: InferenceError) -> Self {
284 match e {
285 InferenceError::NoModelLoaded => OndeError::NoModelLoaded,
286 InferenceError::AlreadyLoaded { model_name } => OndeError::AlreadyLoaded { model_name },
287 InferenceError::ModelBuild { reason } => OndeError::ModelBuild { reason },
288 InferenceError::Inference { reason } => OndeError::Inference { reason },
289 InferenceError::Cancelled => OndeError::Cancelled,
290 InferenceError::Other { reason } => OndeError::Other { reason },
291 }
292 }
293 }
294
295 // ── OndeChatEngine ────────────────────────────────────────────────────────────
296
297 /// Opaque wrapper around `onde::inference::ChatEngine`.
298 ///
299 /// Dart/Flutter receives this as an opaque `RustOpaque<OndeChatEngine>` handle.
300 /// All methods delegate directly to the inner `ChatEngine`.
301 pub struct OndeChatEngine {
302 inner: ChatEngine,
303 }
304
305 impl OndeChatEngine {
306 // ── Construction ──────────────────────────────────────────────────────
307
308 /// Create a new engine with no model loaded.
309 #[frb(sync)]
310 pub fn new() -> OndeChatEngine {
311 OndeChatEngine {
312 inner: ChatEngine::new(),
313 }
314 }
315
316 // ── Model lifecycle ───────────────────────────────────────────────────
317
318 /// Load a GGUF model into the engine.
319 ///
320 /// Returns the load duration in seconds on success.
321 pub async fn load_gguf_model(
322 &self,
323 config: GgufModelConfig,
324 system_prompt: Option<String>,
325 sampling: Option<SamplingConfig>,
326 ) -> Result<f64, OndeError> {
327 let elapsed = self
328 .inner
329 .load_gguf_model(config.into(), system_prompt, sampling.map(Into::into))
330 .await
331 .map_err(OndeError::from)?;
332 Ok(elapsed.as_secs_f64())
333 }
334
335 /// Unload the current model and release its memory.
336 ///
337 /// Returns the display name of the model that was unloaded, or `None`
338 /// if no model was loaded.
339 pub async fn unload_model(&self) -> Option<String> {
340 self.inner.unload_model().await
341 }
342
343 /// Returns `true` if a model is currently loaded and ready.
344 pub async fn is_loaded(&self) -> bool {
345 self.inner.is_loaded().await
346 }
347
348 /// Returns a point-in-time snapshot of the engine's state.
349 pub async fn info(&self) -> EngineInfo {
350 self.inner.info().await.into()
351 }
352
353 // ── Configuration ─────────────────────────────────────────────────────
354
355 /// Set or replace the system prompt.
356 ///
357 /// The prompt is prepended to every inference request and is **not**
358 /// stored in the conversation history.
359 pub async fn set_system_prompt(&self, prompt: String) {
360 self.inner.set_system_prompt(prompt).await;
361 }
362
363 /// Clear the current system prompt.
364 pub async fn clear_system_prompt(&self) {
365 self.inner.clear_system_prompt().await;
366 }
367
368 /// Replace the sampling configuration used for all subsequent inference.
369 pub async fn set_sampling(&self, sampling: SamplingConfig) {
370 self.inner.set_sampling(sampling.into()).await;
371 }
372
373 // ── Conversation history ──────────────────────────────────────────────
374
375 /// Returns a clone of the full conversation history.
376 pub async fn history(&self) -> Vec<ChatMessage> {
377 self.inner
378 .history()
379 .await
380 .into_iter()
381 .map(ChatMessage::from)
382 .collect()
383 }
384
385 /// Clear the conversation history while keeping the model loaded.
386 ///
387 /// Returns the number of turns that were removed.
388 pub async fn clear_history(&self) -> u64 {
389 self.inner.clear_history().await as u64
390 }
391
392 /// Append a message to history without running inference.
393 ///
394 /// Useful for restoring a saved conversation or injecting context.
395 pub async fn push_history(&self, message: ChatMessage) {
396 self.inner.push_history(message.into()).await;
397 }
398
399 // ── Non-streaming inference ───────────────────────────────────────────
400
401 /// Send a user message and receive a complete assistant reply.
402 ///
403 /// Both the user message and assistant reply are appended to the
404 /// conversation history on success.
405 pub async fn send_message(&self, message: String) -> Result<InferenceResult, OndeError> {
406 self.inner
407 .send_message(message)
408 .await
409 .map(InferenceResult::from)
410 .map_err(OndeError::from)
411 }
412
413 /// One-shot generation from an explicit message list.
414 ///
415 /// Does **not** modify the conversation history.
416 pub async fn generate(
417 &self,
418 messages: Vec<ChatMessage>,
419 sampling: Option<SamplingConfig>,
420 ) -> Result<InferenceResult, OndeError> {
421 let onde_messages: Vec<OndeChatMessage> =
422 messages.into_iter().map(OndeChatMessage::from).collect();
423 self.inner
424 .generate(onde_messages, sampling.map(Into::into))
425 .await
426 .map(InferenceResult::from)
427 .map_err(OndeError::from)
428 }
429
430 // ── Streaming inference ───────────────────────────────────────────────
431
432 /// Stream a reply token-by-token.
433 ///
434 /// Each [`StreamChunk`] carries a `delta` string. The final chunk has
435 /// `done == true`. The `sink` is closed automatically when the stream
436 /// ends or if the receiver is dropped.
437 pub async fn stream_message(
438 &self,
439 message: String,
440 sink: StreamSink<StreamChunk>,
441 ) -> Result<(), OndeError> {
442 let mut rx = self
443 .inner
444 .stream_message(message)
445 .await
446 .map_err(OndeError::from)?;
447
448 while let Some(chunk) = rx.recv().await {
449 let bridge_chunk = StreamChunk::from(chunk);
450 let done = bridge_chunk.done;
451 let _ = sink.add(bridge_chunk);
452 if done {
453 break;
454 }
455 }
456
457 Ok(())
458 }
459 }
460
461 // ── Free functions ────────────────────────────────────────────────────────────
462
463 /// Return the platform-appropriate default `GgufModelConfig`.
464 ///
465 /// Selects the 1.5B model on iOS / tvOS / Android and the 3B model on
466 /// macOS / Windows / Linux.
467 #[frb(sync)]
468 pub fn default_model_config() -> GgufModelConfig {
469 OndeGgufModelConfig::platform_default().into()
470 }
471
472 /// `GgufModelConfig` for Qwen 2.5 1.5B Instruct Q4_K_M (~941 MB).
473 #[frb(sync)]
474 pub fn qwen25_1_5b_config() -> GgufModelConfig {
475 OndeGgufModelConfig::qwen25_1_5b().into()
476 }
477
478 /// `GgufModelConfig` for Qwen 2.5 3B Instruct Q4_K_M (~1.93 GB).
479 #[frb(sync)]
480 pub fn qwen25_3b_config() -> GgufModelConfig {
481 OndeGgufModelConfig::qwen25_3b().into()
482 }
483
484 /// `GgufModelConfig` for Qwen 2.5 Coder 1.5B Instruct Q4_K_M (~941 MB).
485 #[frb(sync)]
486 pub fn qwen25_coder_1_5b_config() -> GgufModelConfig {
487 OndeGgufModelConfig::qwen25_coder_1_5b().into()
488 }
489
490 /// `GgufModelConfig` for Qwen 2.5 Coder 3B Instruct Q4_K_M (~1.93 GB).
491 #[frb(sync)]
492 pub fn qwen25_coder_3b_config() -> GgufModelConfig {
493 OndeGgufModelConfig::qwen25_coder_3b().into()
494 }
495
496 /// Default sampling config: `temperature=0.7`, `top_p=0.95`, `max_tokens=512`.
497 #[frb(sync)]
498 pub fn default_sampling_config() -> SamplingConfig {
499 OndeSamplingConfig::default().into()
500 }
501
502 /// Deterministic sampling config: `temperature=0.0`, greedy, `max_tokens=512`.
503 #[frb(sync)]
504 pub fn deterministic_sampling_config() -> SamplingConfig {
505 OndeSamplingConfig::deterministic().into()
506 }
507
508 /// Mobile sampling config: `temperature=0.7`, `top_p=0.95`, `max_tokens=128`.
509 #[frb(sync)]
510 pub fn mobile_sampling_config() -> SamplingConfig {
511 OndeSamplingConfig::mobile().into()
512 }
513
514 /// Seed the HuggingFace cache environment for sandboxed platforms.
515 ///
516 /// On iOS (and Android), the default `~/.cache/huggingface/hub` path is outside
517 /// the app sandbox and writes fail with `Operation not permitted (os error 1)`.
518 ///
519 /// Call this **once** at startup — before any `load_gguf_model` or
520 /// `download_model` — with the app's writable data directory.
521 ///
522 /// # Dart usage
523 ///
524 /// ```dart
525 /// import 'package:path_provider/path_provider.dart';
526 ///
527 /// final dir = await getApplicationSupportDirectory();
528 /// configureCacheDir(appDataDir: dir.path);
529 /// ```
530 ///
531 /// Internally this:
532 /// 1. Creates `<app_data_dir>/huggingface/hub/` if it doesn't exist.
533 /// 2. Sets the `HF_HOME` and `HF_HUB_CACHE` environment variables.
534 /// 3. Seeds `mistralrs_core::GLOBAL_HF_CACHE` so the model loader never
535 /// falls back to `Cache::default()`.
536 #[frb(sync)]
537 pub fn configure_cache_dir(app_data_dir: String) -> Result<(), OndeError> {
538 use std::fs;
539 use std::path::PathBuf;
540
541 let data_dir = PathBuf::from(&app_data_dir);
542 let hf_home = data_dir.join("models");
543 let hf_hub_cache = hf_home.join("hub");
544
545 fs::create_dir_all(&hf_hub_cache).map_err(|e| OndeError::Other {
546 reason: format!(
547 "Cannot create HF cache dir {}: {}",
548 hf_hub_cache.display(),
549 e
550 ),
551 })?;
552
553 // Set env vars BEFORE any hf-hub or mistralrs call. Both libraries
554 // read these to resolve the cache directory — no need to touch
555 // GLOBAL_HF_CACHE directly (that's seeded lazily by load_gguf_model
556 // on Android/iOS when it reads HF_HOME back).
557 std::env::set_var("HF_HOME", &hf_home);
558 std::env::set_var("HF_HUB_CACHE", &hf_hub_cache);
559
560 // Pre-create tmp dir — iOS sandbox also restricts the system TMPDIR.
561 let tmp_dir = data_dir.join("tmp");
562 let _ = fs::create_dir_all(&tmp_dir);
563 std::env::set_var("TMPDIR", &tmp_dir);
564
565 log::info!(
566 "HF cache configured: HF_HOME={}, HF_HUB_CACHE={}",
567 hf_home.display(),
568 hf_hub_cache.display(),
569 );
570
571 Ok(())
572 }