@ondeinference / onde / commits / 1c72793

Fix running on tvOS

Seto Elkahfi committed May 22, 2026 at 21:53 UTC 1c72793776d84d49a1e29d761bd755ebfcf2bdae
4 files changed +231 -2
src/hf_cache.rs
+112
@@ -677,6 +677,106 @@ pub fn repair_hf_cache_symlinks(model_id: &str) {
677 }
678 }
679
680 +/// Ensure snapshot entries exist for known GGUF files.
681 +///
682 +/// When a download completes but the process is killed before hf-hub creates
683 +/// the snapshot symlink, the blob exists but hf-hub considers it a cache miss
684 +/// and re-downloads. This function creates hard links from the snapshot
685 +/// directory to the blob directory for any missing entries.
686 +pub fn ensure_snapshot_entries(model_id: &str, expected_files: &[&str]) {
687 + let cache_path = match model_cache_path(model_id) {
688 + Some(p) if p.is_dir() => p,
689 + _ => return,
690 + };
691 +
692 + let blobs_dir = cache_path.join("blobs");
693 + let snapshots_dir = cache_path.join("snapshots");
694 + let refs_dir = cache_path.join("refs");
695 +
696 + if !blobs_dir.is_dir() {
697 + return;
698 + }
699 +
700 + // Collect non-lock, non-part blob files.
701 + let blob_entries: Vec<(String, u64)> = match fs::read_dir(&blobs_dir) {
702 + Ok(entries) => entries
703 + .flatten()
704 + .filter_map(|e| {
705 + let name = e.file_name().to_string_lossy().to_string();
706 + if name.ends_with(".lock") || name.ends_with(".part") {
707 + return None;
708 + }
709 + let meta = e.metadata().ok()?;
710 + if meta.is_file() && meta.len() > 0 {
711 + Some((name, meta.len()))
712 + } else {
713 + None
714 + }
715 + })
716 + .collect(),
717 + Err(_) => return,
718 + };
719 +
720 + if blob_entries.is_empty() {
721 + return;
722 + }
723 +
724 + // Find the revision from refs/main, or from an existing snapshot dir.
725 + let revision = fs::read_to_string(refs_dir.join("main"))
726 + .ok()
727 + .map(|s| s.trim().to_string())
728 + .or_else(|| {
729 + fs::read_dir(&snapshots_dir)
730 + .ok()?
731 + .flatten()
732 + .find(|e| e.path().is_dir())
733 + .map(|e| e.file_name().to_string_lossy().to_string())
734 + });
735 +
736 + let revision = match revision {
737 + Some(r) if !r.is_empty() => r,
738 + _ => return,
739 + };
740 +
741 + let snap_dir = snapshots_dir.join(&revision);
742 + let _ = fs::create_dir_all(&snap_dir);
743 +
744 + for expected_file in expected_files {
745 + let snap_file = snap_dir.join(expected_file);
746 + if snap_file.exists() {
747 + continue;
748 + }
749 +
750 + // For single-blob repos, link the only blob. For multi-blob repos,
751 + // we can't determine the mapping without metadata.
752 + let blob_to_link = if blob_entries.len() == 1 {
753 + Some(&blob_entries[0].0)
754 + } else {
755 + None
756 + };
757 +
758 + if let Some(blob_name) = blob_to_link {
759 + let blob_path = blobs_dir.join(blob_name);
760 + if fs::hard_link(&blob_path, &snap_file).is_ok() {
761 + info!(
762 + "ensure_snapshot_entries: hard-linked {} → {}",
763 + expected_file, blob_name
764 + );
765 + } else if fs::copy(&blob_path, &snap_file).is_ok() {
766 + info!(
767 + "ensure_snapshot_entries: copied {} → {}",
768 + expected_file, blob_name
769 + );
770 + } else {
771 + warn!(
772 + "ensure_snapshot_entries: failed to link/copy blob for {}",
773 + expected_file
774 + );
775 + }
776 + }
777 + }
778 +}
779 +
780 // ---------------------------------------------------------------------------
781 // Query / mutation functions
782 // ---------------------------------------------------------------------------
@@ -952,6 +1052,18 @@ where
1052 repair_hf_cache_symlinks(&model_id);
1053 ensure_model_cache_dir(&model_id);
1054
1055 + // Recover from interrupted downloads where the blob exists but the
1056 + // snapshot entry was never created (process killed after download,
1057 + // before hf-hub finalised the cache structure).
1058 + let expected_files: Vec<&str> = match model_id.as_str() {
1059 + BARTOWSKI_QWEN25_1_5B_INSTRUCT_GGUF => vec![QWEN25_1_5B_GGUF_FILE],
1060 + BARTOWSKI_QWEN25_3B_INSTRUCT_GGUF => vec![QWEN25_3B_GGUF_FILE],
1061 + _ => vec![],
1062 + };
1063 + if !expected_files.is_empty() {
1064 + ensure_snapshot_entries(&model_id, &expected_files);
1065 + }
1066 +
1067 // Emit an initial progress event based on the actual cache size so that
1068 // resumed downloads don't visually restart from 0%.
1069 let initial_bytes = model_cache_path(&model_id)
src/inference/engine.rs
+46 -2
@@ -391,12 +391,37 @@ impl ChatEngine {
391 crate::hf_cache::clean_stale_lock_files(&config.model_id);
392 crate::hf_cache::repair_hf_cache_symlinks(&config.model_id);
393
394 + // If the blob was downloaded but the process was killed before hf-hub
395 + // created the snapshot entry, hf-hub will re-download. Create the
396 + // missing snapshot hard links so the cache hit succeeds.
397 + let file_refs: Vec<&str> = config.files.iter().map(|s| s.as_str()).collect();
398 + crate::hf_cache::ensure_snapshot_entries(&config.model_id, &file_refs);
399 +
400 let start = Instant::now();
401
402 let mut builder = GgufModelBuilder::new(&config.model_id, config.files.clone())
403 .with_token_source(super::token::hf_token_source())
404 .with_logging();
405
406 + // tvOS: Apple TV 4K has only 4 GB RAM. Reduce memory footprint by
407 + // limiting concurrent sequences, disabling the prefix cache,
408 + // disabling the KV cache, and capping context length at 512 tokens
409 + // so the auto device mapper allocates far less memory.
410 + #[cfg(target_os = "tvos")]
411 + {
412 + use mistralrs_core::AutoDeviceMapParams;
413 + use mistralrs::DeviceMapSetting;
414 +
415 + builder = builder
416 + .with_max_num_seqs(1)
417 + .with_prefix_cache_n(None)
418 + .with_no_kv_cache()
419 + .with_device_mapping(DeviceMapSetting::Auto(AutoDeviceMapParams::Text {
420 + max_seq_len: 512,
421 + max_batch_size: 1,
422 + }));
423 + }
424 +
425 // On Android the GGUF embedded tokenizer is not supported by the
426 // candle backend — an explicit tok_model_id is required.
427 if let Some(ref tok_id) = config.tok_model_id {
@@ -1896,6 +1921,24 @@ const DEEPSEEK_CODER_CHAT_TEMPLATE: &str = include_str!("deepseek_coder_chat_tem
1921 /// These mirror the constants in [`super::models`] but return a ready-to-use
1922 /// [`GgufModelConfig`].
1923 impl GgufModelConfig {
1924 + /// Qwen 2.5 0.5B Instruct (GGUF Q4_K_M), ~380 MB.
1925 + ///
1926 + /// Smallest option. Used on tvOS (Apple TV) where the memory limit is 2 GB.
1927 + pub fn qwen25_0_5b() -> Self {
1928 + Self {
1929 + model_id: super::models::BARTOWSKI_QWEN25_0_5B_INSTRUCT_GGUF.into(),
1930 + files: vec![super::models::QWEN25_0_5B_GGUF_FILE.into()],
1931 + tok_model_id: if cfg!(target_os = "android") {
1932 + Some(super::models::QWEN25_0_5B_TOK_MODEL_ID.into())
1933 + } else {
1934 + None
1935 + },
1936 + display_name: "Qwen 2.5 0.5B".into(),
1937 + approx_memory: "~380 MB (GGUF Q4_K_M)".into(),
1938 + chat_template: None,
1939 + }
1940 + }
1941 +
1942 /// Qwen 2.5 1.5B Instruct (GGUF Q4_K_M) — ~941 MB.
1943 ///
1944 /// Lightest option, ideal for iOS and memory-constrained Android devices.
@@ -2079,9 +2122,10 @@ impl GgufModelConfig {
2122 /// Both are dedicated coding models (trained on 5.5T code + math tokens)
2123 /// and use the `qwen2` GGUF architecture supported by mistral.rs.
2124 pub fn platform_default() -> Self {
2082 - if cfg!(any(
2125 + if cfg!(target_os = "tvos") {
2126 + Self::qwen25_0_5b()
2127 + } else if cfg!(any(
2128 target_os = "ios",
2084 - target_os = "tvos",
2129 target_os = "visionos",
2130 target_os = "watchos",
2131 target_os = "android"
src/inference/ffi.rs
+56
@@ -308,6 +308,12 @@ pub fn default_model_config() -> GgufModelConfig {
308 GgufModelConfig::platform_default()
309 }
310
311 +/// Return the Qwen 2.5 0.5B GGUF model configuration (~380 MB).
312 +#[uniffi::export]
313 +pub fn qwen25_0_5b_config() -> GgufModelConfig {
314 + GgufModelConfig::qwen25_0_5b()
315 +}
316 +
317 /// Return the Qwen 2.5 1.5B GGUF model configuration (~941 MB).
318 #[uniffi::export]
319 pub fn qwen25_1_5b_config() -> GgufModelConfig {
@@ -356,6 +362,41 @@ pub fn assistant_message(content: String) -> ChatMessage {
362 ChatMessage::assistant(content)
363 }
364
365 +/// Configure the HF cache directory for sandboxed platforms.
366 +///
367 +/// Call this before any model-loading method (`load_gguf_model`,
368 +/// `load_default_model`, etc.) on iOS, tvOS, and Android where the
369 +/// default `~/.cache/huggingface` path is inaccessible.
370 +///
371 +/// `path` should be a writable app data directory (e.g. the App Group
372 +/// container or Application Support directory on Apple platforms).
373 +///
374 +/// This sets the `HF_HOME` and `HF_HUB_CACHE` environment variables
375 +/// and creates the directory structure.
376 +#[uniffi::export]
377 +pub fn configure_cache_dir(path: String) {
378 + let base = std::path::PathBuf::from(&path);
379 + let hf_home = base.join("models");
380 + let hf_hub_cache = hf_home.join("hub");
381 +
382 + if let Err(e) = std::fs::create_dir_all(&hf_hub_cache) {
383 + log::warn!(
384 + "configure_cache_dir: could not create {}: {}",
385 + hf_hub_cache.display(),
386 + e
387 + );
388 + }
389 +
390 + std::env::set_var("HF_HOME", &hf_home);
391 + std::env::set_var("HF_HUB_CACHE", &hf_hub_cache);
392 +
393 + log::info!(
394 + "configure_cache_dir: HF_HOME={}, HF_HUB_CACHE={}",
395 + hf_home.display(),
396 + hf_hub_cache.display()
397 + );
398 +}
399 +
400 // ═══════════════════════════════════════════════════════════════════════════
401 // Tests
402 // ═══════════════════════════════════════════════════════════════════════════
@@ -401,6 +442,21 @@ mod tests {
442 assert_eq!(m.role, ChatRole::Assistant);
443 }
444
445 + #[test]
446 + fn configure_cache_dir_sets_env() {
447 + let tmp = std::env::temp_dir().join("onde-test-cache-dir");
448 + configure_cache_dir(tmp.to_string_lossy().to_string());
449 +
450 + let hf_home = std::env::var("HF_HOME").unwrap();
451 + assert!(hf_home.contains("models"));
452 +
453 + let hf_hub = std::env::var("HF_HUB_CACHE").unwrap();
454 + assert!(hf_hub.contains("hub"));
455 +
456 + // Cleanup
457 + let _ = std::fs::remove_dir_all(&tmp);
458 + }
459 +
460 #[tokio::test]
461 async fn onde_chat_engine_new_is_unloaded() {
462 let engine = OndeChatEngine::new();
src/inference/models.rs
+17
@@ -12,6 +12,14 @@ pub const QWEN25_1_5B_GGUF_FILE: &str = "Qwen2.5-1.5B-Instruct-Q4_K_M.gguf";
12 /// embedded in the GGUF file is used instead to avoid an extra network download.
13 pub const QWEN25_1_5B_TOK_MODEL_ID: &str = "Qwen/Qwen2.5-1.5B-Instruct";
14
15 +/// Pre-quantized Qwen 2.5 0.5B Instruct (GGUF Q4_K_M), smallest model (~380 MB).
16 +/// Used on tvOS (Apple TV 4K) where the hard memory limit is 2 GB.
17 +pub const BARTOWSKI_QWEN25_0_5B_INSTRUCT_GGUF: &str = "bartowski/Qwen2.5-0.5B-Instruct-GGUF";
18 +/// The specific GGUF filename to download from the bartowski 0.5B repo.
19 +pub const QWEN25_0_5B_GGUF_FILE: &str = "Qwen2.5-0.5B-Instruct-Q4_K_M.gguf";
20 +/// Base model repo used for the HF tokenizer on Android.
21 +pub const QWEN25_0_5B_TOK_MODEL_ID: &str = "Qwen/Qwen2.5-0.5B-Instruct";
22 +
23 /// Pre-quantized Qwen 2.5 Coder 1.5B Instruct (GGUF Q4_K_M) — dedicated coding model (~941 MB).
24 ///
25 /// Uses the `qwen2` GGUF architecture, identical to Qwen2.5-1.5B-Instruct, so it loads through
@@ -125,6 +133,7 @@ pub const DEEPSEEK_CODER_6_7B_TOK_MODEL_ID: &str = "deepseek-ai/deepseek-coder-6
133 /// Used by `list_local_hf_models` to filter the HuggingFace cache
134 /// to only show models that can actually be used for generation.
135 pub const SUPPORTED_MODELS: &[&str] = &[
136 + BARTOWSKI_QWEN25_0_5B_INSTRUCT_GGUF,
137 BARTOWSKI_QWEN25_1_5B_INSTRUCT_GGUF,
138 BARTOWSKI_QWEN25_3B_INSTRUCT_GGUF,
139 BARTOWSKI_QWEN3_4B_GGUF,
@@ -160,6 +169,13 @@ pub struct SupportedModelInfo {
169 /// When adding a new model, add its constant ID to [`SUPPORTED_MODELS`] **and**
170 /// a corresponding entry here so the frontend can show it in the model list UI.
171 pub const SUPPORTED_MODEL_INFO: &[SupportedModelInfo] = &[
172 + SupportedModelInfo {
173 + id: BARTOWSKI_QWEN25_0_5B_INSTRUCT_GGUF,
174 + name: "Qwen 2.5 0.5B (GGUF)",
175 + org: "Qwen / Alibaba",
176 + description: "Smallest pre-quantized chat model, used on tvOS Apple TV (~380 MB).",
177 + expected_size_bytes: 397_808_192,
178 + },
179 SupportedModelInfo {
180 id: BARTOWSKI_QWEN25_1_5B_INSTRUCT_GGUF,
181 name: "Qwen 2.5 1.5B (GGUF)",
@@ -242,6 +258,7 @@ pub const SUPPORTED_MODEL_INFO: &[SupportedModelInfo] = &[
258 /// embedded tokenizer is used automatically.
259 pub fn tok_model_id_for_repo(hf_repo_id: &str) -> Option<&'static str> {
260 match hf_repo_id {
261 + BARTOWSKI_QWEN25_0_5B_INSTRUCT_GGUF => Some(QWEN25_0_5B_TOK_MODEL_ID),
262 BARTOWSKI_QWEN25_1_5B_INSTRUCT_GGUF => Some(QWEN25_1_5B_TOK_MODEL_ID),
263 BARTOWSKI_QWEN25_3B_INSTRUCT_GGUF => Some(QWEN25_3B_TOK_MODEL_ID),
264 BARTOWSKI_QWEN25_CODER_1_5B_INSTRUCT_GGUF => Some(QWEN25_CODER_1_5B_TOK_MODEL_ID),