Skip to main content

foundry_local_sdk/detail/
model.rs

1//! Public [`Model`] type backed by catalog-owned native handles.
2//!
3//! Mirrors the legacy SDK: a `Model` is either a single variant or a group of
4//! variants sharing an alias. Selection is tracked Rust-side (an index) and all
5//! operations delegate to the selected variant's native handle, so
6//! [`Model::info`] / [`Model::id`] always reflect the current selection.
7
8use std::fmt;
9use std::path::PathBuf;
10use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed};
11use std::sync::Arc;
12
13use super::api::Api;
14use super::info::build_model_info;
15use super::native::NativeModel;
16use super::task::spawn_blocking;
17use crate::error::{FoundryLocalError, Result};
18use crate::types::ModelInfo;
19
20/// One specific variant: its native handle plus the cached, immutable metadata.
21#[derive(Clone)]
22pub(crate) struct VariantData {
23    native: NativeModel,
24    info: ModelInfo,
25}
26
27/// The public model type.
28///
29/// A `Model` may represent either a group of variants (as returned by
30/// [`Catalog::get_model`](crate::Catalog::get_model)) or a single variant (as
31/// returned by [`Catalog::get_model_variant`](crate::Catalog::get_model_variant)
32/// or [`Model::variants`]).
33pub struct Model {
34    inner: ModelKind,
35}
36
37type DownloadProgressCallback = Box<dyn FnMut(f64) + Send + 'static>;
38
39/// Builder for configuring and running a model download.
40///
41/// Use this builder when combining optional settings like progress and cancellation.
42pub struct DownloadBuilder<'a> {
43    model: &'a Model,
44    progress: Option<DownloadProgressCallback>,
45    cancel_flag: Option<Arc<AtomicBool>>,
46}
47
48impl<'a> DownloadBuilder<'a> {
49    fn new(model: &'a Model) -> Self {
50        Self {
51            model,
52            progress: None,
53            cancel_flag: None,
54        }
55    }
56
57    /// Report download progress as a percentage from 0.0 to 100.0.
58    pub fn progress<F>(mut self, callback: F) -> Self
59    where
60        F: FnMut(f64) + Send + 'static,
61    {
62        self.progress = Some(Box::new(callback));
63        self
64    }
65
66    /// Cancel the download when `cancel_flag` is set to `true`.
67    pub fn cancel(mut self, cancel_flag: Arc<AtomicBool>) -> Self {
68        self.cancel_flag = Some(cancel_flag);
69        self
70    }
71
72    /// Run the configured download.
73    pub async fn run(self) -> Result<()> {
74        let native = self.model.selected_variant().native.clone();
75        let progress = self.progress;
76        let cancel_flag = self.cancel_flag;
77        spawn_blocking(move || native.download(progress, cancel_flag)).await
78    }
79}
80enum ModelKind {
81    /// A single model variant (from `get_model_variant` or `variants()`).
82    Variant(Arc<VariantData>),
83    /// A group of variants sharing the same alias (from `get_model`).
84    Group {
85        alias: String,
86        variants: Vec<Arc<VariantData>>,
87        selected: AtomicUsize,
88    },
89}
90
91impl Clone for Model {
92    fn clone(&self) -> Self {
93        Self {
94            inner: match &self.inner {
95                ModelKind::Variant(v) => ModelKind::Variant(v.clone()),
96                ModelKind::Group {
97                    alias,
98                    variants,
99                    selected,
100                } => ModelKind::Group {
101                    alias: alias.clone(),
102                    variants: variants.clone(),
103                    selected: AtomicUsize::new(selected.load(Relaxed)),
104                },
105            },
106        }
107    }
108}
109
110impl fmt::Debug for Model {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match &self.inner {
113            ModelKind::Variant(v) => f
114                .debug_struct("Model::ModelVariant")
115                .field("id", &v.info.id)
116                .field("alias", &v.info.alias)
117                .finish(),
118            ModelKind::Group {
119                alias,
120                variants,
121                selected,
122            } => f
123                .debug_struct("Model::Model")
124                .field("alias", alias)
125                .field("id", &variants[selected.load(Relaxed)].info.id)
126                .field("variants_count", &variants.len())
127                .field("selected_index", &selected.load(Relaxed))
128                .finish(),
129        }
130    }
131}
132
133// ── Construction (crate-internal) ────────────────────────────────────────────
134
135impl Model {
136    /// Wrap a single leaf variant.
137    pub(crate) fn from_variant(api: &Arc<Api>, native: NativeModel) -> Result<Self> {
138        let info = build_model_info(api, &native)?;
139        Ok(Self {
140            inner: ModelKind::Variant(Arc::new(VariantData { native, info })),
141        })
142    }
143
144    /// Wrap an alias-group model, eagerly loading its variants.
145    pub(crate) fn from_group(api: &Arc<Api>, native: NativeModel) -> Result<Self> {
146        let group_info = build_model_info(api, &native)?;
147        let alias = group_info.alias.clone();
148
149        let mut variants = Vec::new();
150        for variant_native in native.get_variants()? {
151            let info = build_model_info(api, &variant_native)?;
152            variants.push(Arc::new(VariantData {
153                native: variant_native,
154                info,
155            }));
156        }
157
158        // A leaf masquerading as a group: fall back to a single-variant model.
159        if variants.is_empty() {
160            return Ok(Self {
161                inner: ModelKind::Variant(Arc::new(VariantData {
162                    native,
163                    info: group_info,
164                })),
165            });
166        }
167
168        // Prefer the first cached variant as the initial selection.
169        let selected = variants.iter().position(|v| v.info.cached).unwrap_or(0);
170
171        Ok(Self {
172            inner: ModelKind::Group {
173                alias,
174                variants,
175                selected: AtomicUsize::new(selected),
176            },
177        })
178    }
179}
180
181// ── Private helpers ──────────────────────────────────────────────────────────
182
183impl Model {
184    fn selected_variant(&self) -> &VariantData {
185        match &self.inner {
186            ModelKind::Variant(v) => v.as_ref(),
187            ModelKind::Group {
188                variants, selected, ..
189            } => variants[selected.load(Relaxed)].as_ref(),
190        }
191    }
192
193    pub(crate) fn selected_native(&self) -> &NativeModel {
194        &self.selected_variant().native
195    }
196}
197
198// ── Public API ───────────────────────────────────────────────────────────────
199
200impl Model {
201    /// Unique identifier of the (selected) variant.
202    pub fn id(&self) -> &str {
203        &self.selected_variant().info.id
204    }
205
206    /// Alias shared by all variants of this model.
207    pub fn alias(&self) -> &str {
208        match &self.inner {
209            ModelKind::Variant(v) => &v.info.alias,
210            ModelKind::Group { alias, .. } => alias,
211        }
212    }
213
214    /// Full catalog metadata for the (selected) variant.
215    ///
216    /// The native model is the source of truth. Each call returns a fresh
217    /// point-in-time snapshot so cache state remains correct after download or
218    /// removal.
219    pub fn info(&self) -> Result<ModelInfo> {
220        let variant = self.selected_variant();
221        build_model_info(&variant.native.api, &variant.native)
222    }
223
224    /// Maximum context length (in tokens), or `None` if unknown.
225    pub fn context_length(&self) -> Option<u64> {
226        self.selected_variant().info.context_length
227    }
228
229    /// Comma-separated input modalities (e.g. `"text,image"`), or `None`.
230    pub fn input_modalities(&self) -> Option<&str> {
231        self.selected_variant().info.input_modalities.as_deref()
232    }
233
234    /// Comma-separated output modalities (e.g. `"text"`), or `None`.
235    pub fn output_modalities(&self) -> Option<&str> {
236        self.selected_variant().info.output_modalities.as_deref()
237    }
238
239    /// Capability tags (e.g. `"reasoning"`), or `None`.
240    pub fn capabilities(&self) -> Option<&str> {
241        self.selected_variant().info.capabilities.as_deref()
242    }
243
244    /// Whether the model supports tool/function calling, or `None`.
245    pub fn supports_tool_calling(&self) -> Option<bool> {
246        self.selected_variant().info.supports_tool_calling
247    }
248
249    /// Whether the (selected) variant is cached on disk.
250    pub async fn is_cached(&self) -> Result<bool> {
251        let native = self.selected_native().clone();
252        spawn_blocking(move || native.is_cached()).await
253    }
254
255    /// Whether the (selected) variant is loaded into memory.
256    pub async fn is_loaded(&self) -> Result<bool> {
257        let native = self.selected_native().clone();
258        spawn_blocking(move || native.is_loaded()).await
259    }
260
261    /// Download the (selected) variant.  If `progress` is provided it
262    /// receives download progress as a percentage (0.0–100.0).
263    pub async fn download<F>(&self, progress: Option<F>) -> Result<()>
264    where
265        F: FnMut(f64) + Send + 'static,
266    {
267        let native = self.selected_native().clone();
268        let progress: Option<DownloadProgressCallback> =
269            progress.map(|f| Box::new(f) as DownloadProgressCallback);
270        spawn_blocking(move || native.download(progress, None)).await
271    }
272
273    /// Configure and run a model download with a builder.
274    ///
275    /// Use this for call sites that need progress, cancellation, or future
276    /// download options.
277    pub fn download_builder(&self) -> DownloadBuilder<'_> {
278        DownloadBuilder::new(self)
279    }
280
281    /// Return the local file-system path of the (selected) variant.
282    pub async fn path(&self) -> Result<PathBuf> {
283        let native = self.selected_native().clone();
284        let id = self.id().to_owned();
285        let path = spawn_blocking(move || native.path()).await?;
286        match path {
287            Some(p) => Ok(PathBuf::from(p)),
288            None => Err(FoundryLocalError::ModelOperation {
289                reason: format!("Error getting path for model {id}. Has it been downloaded?"),
290            }),
291        }
292    }
293
294    /// Load the (selected) variant into memory.
295    pub async fn load(&self) -> Result<()> {
296        let native = self.selected_native().clone();
297        spawn_blocking(move || native.load()).await
298    }
299
300    /// Unload the (selected) variant from memory.
301    pub async fn unload(&self) -> Result<()> {
302        let native = self.selected_native().clone();
303        spawn_blocking(move || native.unload()).await
304    }
305
306    /// Remove the (selected) variant from the local cache.
307    pub async fn remove_from_cache(&self) -> Result<()> {
308        let native = self.selected_native().clone();
309        spawn_blocking(move || native.remove_from_cache()).await
310    }
311
312    /// Create a [`ChatClient`](crate::openai::ChatClient) bound to the (selected) variant.
313    #[deprecated(
314        since = "2.0.0",
315        note = "The OpenAI direct clients are deprecated; use `ChatSession::new(&model)` instead."
316    )]
317    #[allow(deprecated)]
318    pub fn create_chat_client(&self) -> crate::openai::ChatClient {
319        let v = self.selected_variant();
320        crate::openai::ChatClient::new(&v.info.id, v.native.clone())
321    }
322
323    /// Create an [`AudioClient`](crate::openai::AudioClient) bound to the (selected) variant.
324    #[deprecated(
325        since = "2.0.0",
326        note = "The OpenAI direct clients are deprecated; use `AudioSession::new(&model)` instead."
327    )]
328    #[allow(deprecated)]
329    pub fn create_audio_client(&self) -> crate::openai::AudioClient {
330        let v = self.selected_variant();
331        crate::openai::AudioClient::new(&v.info.id, v.native.clone())
332    }
333
334    /// Create an [`EmbeddingClient`](crate::openai::EmbeddingClient) bound to the (selected) variant.
335    #[deprecated(
336        since = "2.0.0",
337        note = "The OpenAI direct clients are deprecated; use `EmbeddingsSession::new(&model)` \
338                instead."
339    )]
340    #[allow(deprecated)]
341    pub fn create_embedding_client(&self) -> crate::openai::EmbeddingClient {
342        let v = self.selected_variant();
343        crate::openai::EmbeddingClient::new(&v.info.id, v.native.clone())
344    }
345
346    /// Available variants of this model.
347    ///
348    /// For a single-variant model (e.g. from
349    /// [`Catalog::get_model_variant`](crate::Catalog::get_model_variant)),
350    /// this returns a single-element list containing itself.
351    pub fn variants(&self) -> Vec<Arc<Model>> {
352        match &self.inner {
353            ModelKind::Variant(v) => {
354                vec![Arc::new(Model {
355                    inner: ModelKind::Variant(v.clone()),
356                })]
357            }
358            ModelKind::Group { variants, .. } => variants
359                .iter()
360                .map(|v| {
361                    Arc::new(Model {
362                        inner: ModelKind::Variant(v.clone()),
363                    })
364                })
365                .collect(),
366        }
367    }
368
369    /// Select a variant to use for subsequent operations.
370    ///
371    /// The `variant` must be one of the models returned by [`variants`](Model::variants).
372    ///
373    /// # Errors
374    ///
375    /// Returns an error if the variant does not belong to this model.
376    /// For single-variant models this always returns an error — use
377    /// [`Catalog::get_model`](crate::Catalog::get_model) to obtain a model
378    /// with all variants available.
379    pub fn select_variant(&self, variant: &Model) -> Result<()> {
380        self.select_variant_by_id(variant.id())
381    }
382
383    /// Select a variant by its unique id string.
384    ///
385    /// This is a convenience method for cases where you have a variant id
386    /// from an external source. Prefer [`select_variant`](Model::select_variant)
387    /// when you already have a [`Model`] reference from [`variants`](Model::variants).
388    ///
389    /// # Errors
390    ///
391    /// Returns an error if no variant with the given id exists.
392    /// For single-variant models this always returns an error — use
393    /// [`Catalog::get_model`](crate::Catalog::get_model) to obtain a model
394    /// with all variants available.
395    pub fn select_variant_by_id(&self, id: &str) -> Result<()> {
396        match &self.inner {
397            ModelKind::Variant(v) => Err(FoundryLocalError::ModelOperation {
398                reason: format!(
399                    "Selecting a variant is not supported on a single-variant model. \
400                     Call Catalog::get_model(\"{}\") to get a model with all variants available.",
401                    v.info.alias
402                ),
403            }),
404            ModelKind::Group {
405                variants,
406                selected,
407                alias,
408            } => match variants.iter().position(|v| v.info.id == id) {
409                Some(pos) => {
410                    selected.store(pos, Relaxed);
411                    Ok(())
412                }
413                None => {
414                    let available: Vec<&str> =
415                        variants.iter().map(|v| v.info.id.as_str()).collect();
416                    Err(FoundryLocalError::ModelOperation {
417                        reason: format!(
418                            "Variant '{id}' not found for model '{alias}'. Available: {available:?}",
419                        ),
420                    })
421                }
422            },
423        }
424    }
425}