Skip to main content

rig_candle/
model.rs

1//! Local, CPU-only Llama-compatible and Qwen3 inference for Rig, backed by Candle.
2//!
3//! Models are loaded entirely from caller-provided owned or borrowed byte
4//! buffers. This crate performs no filesystem or network access. On
5//! `wasm32-unknown-unknown`, inference runs
6//! synchronously inside the completion future; browser applications should own
7//! and invoke the model in a Web Worker to avoid blocking the UI thread.
8//!
9//! ```no_run
10//! use rig_agent::{agent::AgentBuilder, completion::Prompt};
11//! use rig_candle::{CandleModel, ModelData};
12//!
13//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
14//! let data = ModelData {
15//!     config: std::fs::read("./model/config.json")?,
16//!     tokenizer: std::fs::read("./model/tokenizer.json")?,
17//!     weights: std::fs::read("./model/model.safetensors")?,
18//! };
19//! let model = CandleModel::from_safetensors_async(data).await?;
20//! let agent = AgentBuilder::new(model)
21//!     .preamble("You are a helpful assistant.")
22//!     .temperature(0.7)
23//!     .max_tokens(256)
24//!     .build();
25//! let answer = agent.prompt("Explain Rust ownership briefly.").await?;
26//! println!("{answer}");
27//! # Ok(())
28//! # }
29//! ```
30//!
31//! The validated profiles are unsharded Llama 3 safetensors,
32//! SmolLM2-360M-Instruct Q4_K_M GGUF, and (on native targets) the official
33//! Qwen3-4B Q4_K_M GGUF. Conversation rendering is explicit; tokenizer-provided
34//! templates are validated where necessary but never executed.
35//!
36//! Qwen3 supports Rig function definitions, all portable `ToolChoice` modes,
37//! assistant tool-call history, correlated text/JSON tool results, buffered
38//! agent runs, and streaming agent runs. Qwen control markup is buffered for
39//! one model turn before complete tool calls are emitted, so partial XML never
40//! leaks as assistant text. Tool arguments are checked for JSON object syntax;
41//! the registered Rig tool remains responsible for typed/schema validation.
42//! Direct `CompletionRequest::output_schema` is rejected because decoding is
43//! not grammar constrained. Agent `OutputMode::Tool` is supported through Rig's
44//! synthetic final-result tool.
45//!
46//! Request `max_tokens` and `temperature` override builder defaults. The
47//! Candle-specific `additional_params` keys are `top_k`, `top_p`, `seed`,
48//! `repeat_penalty`, and `repeat_last_n`; unknown keys are rejected. Output is
49//! clamped to the context capacity remaining after tokenizing the prompt.
50//!
51//! Native inference is admitted asynchronously and runs in `spawn_blocking`.
52//! [`CandleModelBuilder::max_concurrent_requests`] defaults to one to control CPU
53//! and KV-cache memory pressure. Dropping a native completion future signals
54//! cooperative cancellation. Streaming uses an eight-fragment bounded channel;
55//! dropping the stream signals the same cancellation while keeping the admission
56//! permit until the blocking worker exits. A forward operation already in progress
57//! cannot be interrupted, so cancellation is observed at the next generation
58//! boundary. WASM does not use native synchronization or threads and collects its
59//! synchronously generated events before exposing them as a compatible stream.
60//!
61//! Multimodal content, accelerators, shards, arbitrary tokenizer chat templates,
62//! provider-hosted tools, and in-crate downloads are unsupported.
63
64use std::sync::Arc;
65
66#[cfg(not(target_family = "wasm"))]
67use futures::Stream;
68use rig_core::completion::{
69    CompletionError, CompletionModel, CompletionRequest, CompletionResponse,
70};
71#[cfg(test)]
72use rig_core::message::{Message, UserContent};
73use rig_core::streaming::{RawStreamingChoice, StreamingCompletionResponse, StreamingResult};
74#[cfg(test)]
75use tokenizers::Tokenizer;
76
77use crate::artifacts::{GgufModelData, ModelArtifacts, ModelData};
78use crate::generation::{GenerationConfig, infer, stream_generate, validate_generation};
79#[cfg(test)]
80use crate::generation::{
81    IncrementalTextDecoder, effective_generation, effective_output_limit, max_tokens_to_usize,
82    next_cache_position, recent_tokens, sampling,
83};
84#[cfg(test)]
85use crate::loader::*;
86use crate::loader::{LoadedModel, load_gguf_model, load_model_with_family};
87#[cfg(test)]
88use crate::profile::{ArtifactFormat, LoaderBackend, definition_for};
89#[cfg(test)]
90use crate::profile::{BEGIN_OF_TEXT, END_HEADER, END_OF_TURN, IM_END, IM_START, START_HEADER};
91use crate::profile::{ConversationProtocol, ModelArchitecture, ModelFamily, Quantization};
92use crate::runtime::CancellationSignal;
93#[cfg(all(test, not(target_family = "wasm")))]
94use crate::runtime::TestControl;
95#[cfg(not(target_family = "wasm"))]
96use crate::runtime::{CancelOnDrop, acquire_concurrency};
97use crate::types::*;
98#[cfg(test)]
99use crate::validation::*;
100
101const DEFAULT_MAX_CONCURRENT_REQUESTS: usize = 1;
102#[cfg(not(target_family = "wasm"))]
103const STREAM_CHANNEL_CAPACITY: usize = 8;
104
105#[derive(Clone)]
106enum ModelState {
107    Ready(Arc<LoadedModel>),
108    UnsupportedMake,
109}
110
111/// A cheaply cloneable, CPU-only Candle completion model.
112#[derive(Clone)]
113pub struct CandleModel {
114    state: ModelState,
115}
116
117/// Builder for loading a [`CandleModel`] and customizing generation defaults.
118pub struct CandleModelBuilder<'a> {
119    source: ModelSource<'a>,
120    family: Option<ModelFamily>,
121    generation: GenerationConfig,
122    max_concurrent_requests: usize,
123}
124
125enum ModelSource<'a> {
126    Owned(ModelArtifacts),
127    BorrowedGguf(GgufModelData<'a>),
128}
129
130/// Backwards-compatible alias for [`CandleModel`].
131///
132/// New code should use `CandleModel`, which accurately reflects that the
133/// backend also supports validated Qwen3 checkpoints.
134pub type LlamaModel = CandleModel;
135
136/// Backwards-compatible alias for [`CandleModelBuilder`].
137pub type LlamaModelBuilder<'a> = CandleModelBuilder<'a>;
138
139impl CandleModel {
140    /// Loads a model from config, tokenizer, and one unsharded safetensors buffer.
141    pub fn from_safetensors(data: ModelData) -> Result<Self, CandleError> {
142        Self::builder(data).build()
143    }
144
145    /// Loads a model from config, tokenizer, and a byte-backed GGUF checkpoint.
146    pub fn from_gguf(data: ModelData) -> Result<Self, CandleError> {
147        Self::builder_from_artifacts(ModelArtifacts::Gguf(data)).build()
148    }
149
150    /// Loads GGUF artifacts from borrowed bytes without copying the checkpoint buffer.
151    ///
152    /// This is intended for `include_bytes!` and other long-lived buffers where
153    /// the GGUF bytes are needed only while Candle constructs its owned tensors.
154    pub fn from_gguf_bytes(data: GgufModelData<'_>) -> Result<Self, CandleError> {
155        Self::builder_from_gguf_bytes(data).build()
156    }
157
158    /// Loads a model from explicitly typed byte-backed artifacts.
159    pub fn from_artifacts(artifacts: ModelArtifacts) -> Result<Self, CandleError> {
160        Self::builder_from_artifacts(artifacts).build()
161    }
162
163    /// Starts a byte-backed model builder.
164    pub fn builder(data: ModelData) -> CandleModelBuilder<'static> {
165        Self::builder_from_artifacts(ModelArtifacts::Safetensors(data))
166    }
167
168    /// Starts a builder from explicitly typed byte-backed artifacts.
169    pub fn builder_from_artifacts(artifacts: ModelArtifacts) -> CandleModelBuilder<'static> {
170        CandleModelBuilder {
171            source: ModelSource::Owned(artifacts),
172            family: None,
173            generation: GenerationConfig::default(),
174            max_concurrent_requests: DEFAULT_MAX_CONCURRENT_REQUESTS,
175        }
176    }
177
178    /// Starts a GGUF builder without copying any artifact buffer.
179    ///
180    /// All generation and concurrency settings available to owned artifacts
181    /// are also available here. The buffers only need to remain valid until
182    /// [`CandleModelBuilder::build`] returns because Candle owns loaded tensors.
183    pub fn builder_from_gguf_bytes<'a>(data: GgufModelData<'a>) -> CandleModelBuilder<'a> {
184        CandleModelBuilder {
185            source: ModelSource::BorrowedGguf(data),
186            family: None,
187            generation: GenerationConfig::default(),
188            max_concurrent_requests: DEFAULT_MAX_CONCURRENT_REQUESTS,
189        }
190    }
191
192    /// Asynchronously loads owned safetensors artifacts outside the async executor.
193    #[cfg(not(target_family = "wasm"))]
194    pub async fn from_safetensors_async(data: ModelData) -> Result<Self, CandleError> {
195        Self::builder(data).build_async().await
196    }
197
198    /// Asynchronously loads owned GGUF artifacts outside the async executor.
199    #[cfg(not(target_family = "wasm"))]
200    pub async fn from_gguf_async(data: ModelData) -> Result<Self, CandleError> {
201        Self::builder_from_artifacts(ModelArtifacts::Gguf(data))
202            .build_async()
203            .await
204    }
205
206    /// Asynchronously loads borrowed static GGUF artifacts outside the async executor.
207    ///
208    /// Static buffers such as `include_bytes!` remain zero-copy at the API
209    /// boundary and satisfy the blocking task's ownership requirement.
210    #[cfg(not(target_family = "wasm"))]
211    pub async fn from_gguf_bytes_async(data: GgufModelData<'static>) -> Result<Self, CandleError> {
212        Self::builder_from_gguf_bytes(data).build_async().await
213    }
214
215    /// Asynchronously loads explicitly typed owned artifacts outside the async executor.
216    #[cfg(not(target_family = "wasm"))]
217    pub async fn from_artifacts_async(artifacts: ModelArtifacts) -> Result<Self, CandleError> {
218        Self::builder_from_artifacts(artifacts).build_async().await
219    }
220
221    /// Returns the validated conversation/output protocol.
222    pub fn conversation_protocol(&self) -> Option<ConversationProtocol> {
223        match &self.state {
224            ModelState::Ready(loaded) => Some(loaded.profile.definition.protocol),
225            ModelState::UnsupportedMake => None,
226        }
227    }
228
229    /// Backwards-compatible alias for [`Self::conversation_protocol`].
230    pub fn model_family(&self) -> Option<ModelFamily> {
231        self.conversation_protocol()
232    }
233
234    /// Returns the validated transformer architecture of the loaded checkpoint.
235    pub fn architecture(&self) -> Option<ModelArchitecture> {
236        match &self.state {
237            ModelState::Ready(loaded) => Some(loaded.profile.definition.architecture),
238            ModelState::UnsupportedMake => None,
239        }
240    }
241
242    /// Returns the detected checkpoint quantization, if the model is quantized.
243    pub fn quantization(&self) -> Option<Quantization> {
244        match &self.state {
245            ModelState::Ready(loaded) => loaded.profile.definition.quantization,
246            ModelState::UnsupportedMake => None,
247        }
248    }
249}
250
251impl<'a> CandleModelBuilder<'a> {
252    /// Selects a conversation protocol and requires it to match the artifacts.
253    pub fn conversation_protocol(mut self, protocol: ConversationProtocol) -> Self {
254        self.family = Some(protocol);
255        self
256    }
257
258    /// Backwards-compatible alias for [`Self::conversation_protocol`].
259    pub fn model_family(mut self, family: ModelFamily) -> Self {
260        self.family = Some(family);
261        self
262    }
263    /// Sets the default maximum generated token count.
264    pub fn max_tokens(mut self, max_tokens: u64) -> Self {
265        self.generation.max_tokens = max_tokens;
266        self
267    }
268
269    /// Sets the default sampling temperature. Zero enables greedy decoding.
270    pub fn temperature(mut self, temperature: f64) -> Self {
271        self.generation.temperature = temperature;
272        self
273    }
274
275    /// Sets the default deterministic sampling seed.
276    pub fn seed(mut self, seed: u64) -> Self {
277        self.generation.seed = seed;
278        self
279    }
280
281    /// Sets or disables the default top-k sampling limit.
282    pub fn top_k(mut self, top_k: Option<usize>) -> Self {
283        self.generation.top_k = top_k;
284        self
285    }
286
287    /// Sets or disables the default nucleus-sampling threshold.
288    pub fn top_p(mut self, top_p: Option<f64>) -> Self {
289        self.generation.top_p = top_p;
290        self
291    }
292
293    /// Sets the default repeat penalty.
294    pub fn repeat_penalty(mut self, repeat_penalty: f32) -> Self {
295        self.generation.repeat_penalty = repeat_penalty;
296        self
297    }
298
299    /// Sets the default number of recent tokens used by the repeat penalty.
300    pub fn repeat_last_n(mut self, repeat_last_n: usize) -> Self {
301        self.generation.repeat_last_n = repeat_last_n;
302        self
303    }
304
305    /// Sets the maximum number of native inference requests admitted concurrently.
306    ///
307    /// The default is one to avoid CPU oversubscription and concurrent KV-cache
308    /// memory spikes. WASM inference is synchronous and does not use this limit.
309    pub fn max_concurrent_requests(mut self, max_concurrent_requests: usize) -> Self {
310        self.max_concurrent_requests = max_concurrent_requests;
311        self
312    }
313
314    /// Validates all artifacts and loads model tensors onto the CPU.
315    pub fn build(self) -> Result<CandleModel, CandleError> {
316        validate_generation(&self.generation, None)?;
317        if self.max_concurrent_requests == 0 {
318            return Err(CandleError::InvalidConcurrencyLimit);
319        }
320        let loaded = match self.source {
321            ModelSource::Owned(artifacts) => load_model_with_family(
322                artifacts,
323                self.family,
324                self.generation,
325                self.max_concurrent_requests,
326            )?,
327            ModelSource::BorrowedGguf(data) => load_gguf_model(
328                data,
329                self.family,
330                self.generation,
331                self.max_concurrent_requests,
332            )?,
333        };
334        Ok(CandleModel {
335            state: ModelState::Ready(Arc::new(loaded)),
336        })
337    }
338}
339
340#[cfg(not(target_family = "wasm"))]
341impl CandleModelBuilder<'static> {
342    /// Validates and loads model artifacts on Tokio's blocking thread pool.
343    ///
344    /// Dropping the returned future does not stop a load that has already
345    /// started; Tokio keeps admitted blocking work running to completion.
346    pub async fn build_async(self) -> Result<CandleModel, CandleError> {
347        join_model_load(tokio::task::spawn_blocking(move || self.build())).await
348    }
349}
350
351#[cfg(not(target_family = "wasm"))]
352async fn join_model_load(
353    task: tokio::task::JoinHandle<Result<CandleModel, CandleError>>,
354) -> Result<CandleModel, CandleError> {
355    task.await
356        .map_err(|error| CandleError::BlockingTaskJoin(error.to_string()))?
357}
358
359#[cfg(test)]
360fn render_prompt(request: &CompletionRequest) -> Result<String, CandleError> {
361    render_prompt_for(request, ModelFamily::Llama3)
362}
363
364#[cfg(test)]
365fn render_prompt_for(
366    request: &CompletionRequest,
367    family: ModelFamily,
368) -> Result<String, CandleError> {
369    crate::protocol::render_prompt(request, family)
370}
371
372#[cfg(not(target_family = "wasm"))]
373type CandleStreamItem = Result<RawStreamingChoice<CandleCompletionResponse>, CompletionError>;
374
375#[cfg(not(target_family = "wasm"))]
376struct CandleReceiverStream {
377    receiver: tokio::sync::mpsc::Receiver<CandleStreamItem>,
378    cancellation: CancellationSignal,
379}
380
381#[cfg(not(target_family = "wasm"))]
382impl Stream for CandleReceiverStream {
383    type Item = CandleStreamItem;
384
385    fn poll_next(
386        self: std::pin::Pin<&mut Self>,
387        context: &mut std::task::Context<'_>,
388    ) -> std::task::Poll<Option<Self::Item>> {
389        self.get_mut().receiver.poll_recv(context)
390    }
391}
392
393#[cfg(not(target_family = "wasm"))]
394impl Drop for CandleReceiverStream {
395    fn drop(&mut self) {
396        self.cancellation.cancel();
397    }
398}
399
400#[cfg(not(target_family = "wasm"))]
401fn stream_infer(
402    loaded: &LoadedModel,
403    request: CompletionRequest,
404    cancellation: &CancellationSignal,
405    sender: &tokio::sync::mpsc::Sender<CandleStreamItem>,
406) -> Result<(), CandleError> {
407    let response = stream_generate(loaded, request, cancellation, |choice| {
408        #[cfg(test)]
409        if let Some(control) = &loaded.test_control {
410            control.record_delivery_attempt();
411        }
412        sender
413            .blocking_send(Ok(choice))
414            .map_err(|_| CandleError::StreamingChannelClosed)
415    })?;
416    sender
417        .blocking_send(Ok(RawStreamingChoice::FinalResponse(response)))
418        .map_err(|_| CandleError::StreamingChannelClosed)
419}
420
421impl CompletionModel for CandleModel {
422    type Response = CandleCompletionResponse;
423    type StreamingResponse = CandleCompletionResponse;
424    type Client = ();
425
426    fn make(_: &Self::Client, _: impl Into<String>) -> Self {
427        Self {
428            state: ModelState::UnsupportedMake,
429        }
430    }
431
432    async fn completion(
433        &self,
434        request: CompletionRequest,
435    ) -> Result<CompletionResponse<Self::Response>, CompletionError> {
436        let ModelState::Ready(loaded) = &self.state else {
437            return Err(CandleError::UnsupportedMake.into());
438        };
439
440        #[cfg(not(target_family = "wasm"))]
441        {
442            let cancellation = CancellationSignal::default();
443            let mut cancel_on_drop = CancelOnDrop::new(cancellation.clone());
444            let permit = acquire_concurrency(Arc::clone(&loaded.concurrency)).await?;
445            let loaded = Arc::clone(loaded);
446            let result = tokio::task::spawn_blocking(move || {
447                let result = loaded
448                    .runtime
449                    .device()
450                    .with_context(|| infer(&loaded, request, &cancellation));
451                drop(permit);
452                result
453            })
454            .await
455            .map_err(|error| CandleError::BlockingTaskJoin(error.to_string()));
456            cancel_on_drop.disarm();
457            result?.map_err(CompletionError::from)
458        }
459
460        #[cfg(target_family = "wasm")]
461        {
462            infer(loaded, request, &CancellationSignal).map_err(CompletionError::from)
463        }
464    }
465
466    async fn stream(
467        &self,
468        request: CompletionRequest,
469    ) -> Result<StreamingCompletionResponse<Self::StreamingResponse>, CompletionError> {
470        let ModelState::Ready(loaded) = &self.state else {
471            return Err(CandleError::UnsupportedMake.into());
472        };
473
474        #[cfg(not(target_family = "wasm"))]
475        {
476            let cancellation = CancellationSignal::default();
477            let mut cancel_on_drop = CancelOnDrop::new(cancellation.clone());
478            let permit = acquire_concurrency(Arc::clone(&loaded.concurrency)).await?;
479            let loaded = Arc::clone(loaded);
480            let (sender, receiver) = tokio::sync::mpsc::channel(STREAM_CHANNEL_CAPACITY);
481            let producer_sender = sender.clone();
482            let producer_cancellation = cancellation.clone();
483            let task = tokio::task::spawn_blocking(move || {
484                let result = loaded.runtime.device().with_context(|| {
485                    stream_infer(&loaded, request, &producer_cancellation, &producer_sender)
486                });
487                if let Err(error) = result {
488                    let _ = producer_sender.blocking_send(Err(error.into()));
489                }
490                drop(permit);
491            });
492            tokio::spawn(async move {
493                if let Err(error) = task.await {
494                    let error = CandleError::BlockingTaskJoin(error.to_string());
495                    let _ = sender.send(Err(error.into())).await;
496                }
497            });
498            let stream: StreamingResult<CandleCompletionResponse> =
499                Box::pin(CandleReceiverStream {
500                    receiver,
501                    cancellation,
502                });
503            cancel_on_drop.disarm();
504            Ok(StreamingCompletionResponse::stream(stream))
505        }
506
507        #[cfg(target_family = "wasm")]
508        {
509            let mut events = Vec::new();
510            let response = stream_generate(loaded, request, &CancellationSignal, |choice| {
511                events.push(Ok(choice));
512                Ok(())
513            })?;
514            events.push(Ok(RawStreamingChoice::FinalResponse(response)));
515            let stream: StreamingResult<CandleCompletionResponse> =
516                Box::pin(futures::stream::iter(events));
517            Ok(StreamingCompletionResponse::stream(stream))
518        }
519    }
520}
521
522#[cfg(test)]
523#[allow(clippy::panic_in_result_fn)]
524mod tests;