Skip to main content

rig_core/providers/
llamafile.rs

1//! Llamafile API client and Rig integration
2//!
3//! [Llamafile](https://github.com/Mozilla-Ocho/llamafile) is a Mozilla Builders project
4//! that distributes LLMs as single-file executables. When started, it exposes an
5//! OpenAI-compatible API at `http://localhost:8080/v1`.
6//!
7//! # Example
8//! ```no_run
9//! use rig_core::{
10//!     client::CompletionClient,
11//!     completion::CompletionModel,
12//!     providers::llamafile,
13//! };
14//!
15//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
16//! // Create a new Llamafile client (defaults to http://localhost:8080)
17//! let client = llamafile::Client::from_url("http://localhost:8080")?;
18//!
19//! // Send a completion request with a preamble.
20//! let model = client.completion_model(llamafile::LLAMA_CPP);
21//! let request = model
22//!     .completion_request("Hello!")
23//!     .preamble("You are a helpful assistant.".to_string())
24//!     .build();
25//! let response = model.completion(request).await?;
26//! println!("{:?}", response.choice);
27//! # Ok(())
28//! # }
29//! ```
30
31use crate::client::{self, DebugExt, Nothing, Provider, ProviderClient, Transport};
32use crate::providers::openai;
33
34// ================================================================
35// Main Llamafile Client
36// ================================================================
37const LLAMAFILE_API_BASE_URL: &str = "http://localhost:8080";
38
39/// The default model identifier reported by llamafile.
40pub const LLAMA_CPP: &str = "LLaMA_CPP";
41
42#[derive(Debug, Default, Clone, Copy)]
43pub struct LlamafileExt;
44
45#[derive(Debug, Default, Clone, Copy)]
46pub struct LlamafileBuilder;
47
48impl Provider for LlamafileExt {
49    type Builder = LlamafileBuilder;
50    const VERIFY_PATH: &'static str = "/models";
51
52    // Llamafile clients are constructed from a bare host URL
53    // (e.g. `http://localhost:8080`) while the shared OpenAI-compatible
54    // endpoints are relative to `/v1`.
55    fn build_uri(&self, base_url: &str, path: &str, _transport: Transport) -> String {
56        let base_url = base_url.trim_end_matches('/');
57        format!("{base_url}/v1/{}", path.trim_start_matches('/'))
58    }
59}
60
61impl openai::completion::OpenAICompatibleProvider for LlamafileExt {
62    const PROVIDER_NAME: &'static str = "llamafile";
63
64    type StreamingUsage = openai::Usage;
65
66    // llama.cpp-based servers can emit a whole tool call in one streaming chunk.
67    const EMITS_COMPLETE_SINGLE_CHUNK_TOOL_CALLS: bool = true;
68
69    type Response = openai::CompletionResponse;
70}
71
72impl openai::embedding::OpenAIEmbeddingsCompatible for LlamafileExt {
73    const PROVIDER_NAME: &'static str = "llamafile";
74}
75
76client::impl_capabilities!(
77    LlamafileExt,
78    completion = openai::completion::GenericCompletionModel<LlamafileExt, H>,
79    embeddings = openai::embedding::GenericEmbeddingModel<LlamafileExt, H>,
80);
81
82impl DebugExt for LlamafileExt {}
83
84client::impl_default_provider_builder!(
85    LlamafileBuilder => LlamafileExt,
86    api_key = Nothing,
87    base_url = LLAMAFILE_API_BASE_URL,
88);
89
90pub type Client<H = reqwest::Client> = client::Client<LlamafileExt, H>;
91pub type ClientBuilder<H = crate::markers::Missing> =
92    client::ClientBuilder<LlamafileBuilder, Nothing, H>;
93
94/// Llamafile completion model, driven by the shared OpenAI Chat Completions path.
95pub type CompletionModel<H = reqwest::Client> =
96    openai::completion::GenericCompletionModel<LlamafileExt, H>;
97
98/// Llamafile embedding model, driven by the shared OpenAI embeddings path.
99pub type EmbeddingModel<H = reqwest::Client> =
100    openai::embedding::GenericEmbeddingModel<LlamafileExt, H>;
101
102impl Client {
103    /// Create a client pointing at the given llamafile base URL
104    /// (e.g. `http://localhost:8080`).
105    pub fn from_url(base_url: &str) -> crate::client::ProviderClientResult<Self> {
106        Self::builder()
107            .api_key(Nothing)
108            .base_url(base_url)
109            .build()
110            .map_err(Into::into)
111    }
112}
113
114impl ProviderClient for Client {
115    type Input = Nothing;
116    type Error = crate::client::ProviderClientError;
117
118    fn from_env() -> Result<Self, Self::Error> {
119        let api_base = crate::client::required_env_var("LLAMAFILE_API_BASE_URL")?;
120        Self::from_url(&api_base)
121    }
122
123    fn from_val(_: Self::Input) -> Result<Self, Self::Error> {
124        Self::builder().api_key(Nothing).build().map_err(Into::into)
125    }
126}
127
128// ================================================================
129// Tests
130// ================================================================
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::client::{EmbeddingsClient, Nothing};
135    use crate::embeddings::EmbeddingModel as _;
136    use crate::providers::openai::embedding::EncodingFormat;
137    use crate::test_utils::RecordingHttpClient;
138
139    #[test]
140    fn test_client_initialization() {
141        let _client =
142            crate::providers::llamafile::Client::new(Nothing).expect("Client::new() failed");
143        let _client_from_builder = crate::providers::llamafile::Client::builder()
144            .api_key(Nothing)
145            .build()
146            .expect("Client::builder() failed");
147    }
148
149    #[test]
150    fn test_client_from_url() {
151        let _client = crate::providers::llamafile::Client::from_url("http://localhost:8080");
152    }
153
154    #[test]
155    fn test_build_uri_routes_through_v1() {
156        let ext = LlamafileExt;
157        assert_eq!(
158            ext.build_uri(
159                "http://localhost:8080",
160                "/chat/completions",
161                Transport::Http
162            ),
163            "http://localhost:8080/v1/chat/completions"
164        );
165        assert_eq!(
166            ext.build_uri("http://localhost:8080/", "/embeddings", Transport::Http),
167            "http://localhost:8080/v1/embeddings"
168        );
169        assert_eq!(
170            ext.build_uri(
171                "http://localhost:8080",
172                LlamafileExt::VERIFY_PATH,
173                Transport::Http
174            ),
175            "http://localhost:8080/v1/models"
176        );
177    }
178
179    #[tokio::test]
180    async fn embedding_model_preserves_v1_path_and_usage() {
181        let response = r#"{
182            "object": "list",
183            "model": "LLaMA_CPP",
184            "usage": { "prompt_tokens": 2, "total_tokens": 2 },
185            "data": [{ "object": "embedding", "index": 0, "embedding": [0.1, 0.2] }]
186        }"#;
187        let http_client = RecordingHttpClient::new(response);
188        let client = Client::builder()
189            .api_key(Nothing)
190            .http_client(http_client.clone())
191            .build()
192            .expect("client should build");
193        let model = client.embedding_model(LLAMA_CPP);
194
195        let response = model
196            .embed_texts_with_usage(["hello".to_string()])
197            .await
198            .expect("embedding request should succeed");
199
200        assert_eq!(response.usage.total_tokens, 2);
201        assert_eq!(
202            http_client.requests()[0].uri,
203            "http://localhost:8080/v1/embeddings"
204        );
205    }
206
207    #[tokio::test]
208    async fn embedding_model_rejects_base64_before_sending() {
209        let http_client = RecordingHttpClient::new("{}");
210        let client = Client::builder()
211            .api_key(Nothing)
212            .http_client(http_client.clone())
213            .build()
214            .expect("client should build");
215        let model = client
216            .embedding_model(LLAMA_CPP)
217            .encoding_format(EncodingFormat::Base64);
218
219        let error = model
220            .embed_texts(["hello".to_string()])
221            .await
222            .expect_err("numeric response parser should reject base64");
223
224        assert!(matches!(
225            error,
226            crate::embeddings::EmbeddingError::UnsupportedResponseEncoding {
227                provider: "llamafile",
228                encoding_format: "base64"
229            }
230        ));
231        assert!(http_client.requests().is_empty());
232    }
233}