Skip to main content

yapi_core/
llama.rs

1//! pi's built-in `llama.cpp` extension: the `/llama` command, which lists a
2//! llama.cpp router's models, loads and unloads them, and downloads new ones
3//! from Hugging Face. The provider itself lives in `yapi_ai::llama`.
4//!
5//! Port of `extensions/llama/index.ts` in pi-coding-agent `v1.0.0`. pi draws
6//! the manager as its own components. yapi asks through the standard dialogs.
7
8use std::sync::Arc;
9
10use futures_util::future::BoxFuture;
11use tokio_util::sync::CancellationToken;
12use yapi_ai::llama::{self, Client, ModelInfo, Progress, huggingface};
13use yapi_ai::model_catalog::RefreshOptions;
14use yapi_types::rpc::SourceInfo;
15
16use crate::extensions::{
17    Command, Context, DialogOptions, Extension, ExtensionUi, Mode, NotifyKind, builtin_source,
18};
19
20/// The extension's name, as `builtin:llama.cpp` selects it.
21pub const NAME: &str = "llama.cpp";
22const STATUS: &str = "llama.cpp";
23const DOWNLOAD: &str = "Download a model…";
24
25/// pi's built-in `llama.cpp` extension.
26#[derive(Debug, Default)]
27pub struct LlamaExtension;
28
29fn context_label(model: &ModelInfo) -> Option<String> {
30    let meta = model.meta.clone().unwrap_or_default();
31    let label = |value: u64| {
32        if value >= 1000 {
33            format!("{}k", yapi_types::js::round(value as f64 / 1000.0))
34        } else {
35            value.to_string()
36        }
37    };
38    if let Some(context) = meta.n_ctx.or(meta.n_ctx_train).filter(|value| *value > 0) {
39        return Some(label(context));
40    }
41    let args = model.status.args.as_deref().unwrap_or_default();
42    args.windows(2).find_map(|pair| {
43        matches!(pair[0].as_str(), "--ctx-size" | "-c" | "-ctx")
44            .then(|| pair[1].parse::<f64>().ok())
45            .flatten()
46            .filter(|value| value.is_finite() && *value > 0.0)
47            .map(|value| label(value as u64))
48    })
49}
50
51/// pi's `modelDescription`: the state, and the context of a loaded model.
52fn description(model: &ModelInfo) -> String {
53    let mut details = Vec::new();
54    let loaded = model.is_loaded();
55    if loaded {
56        details.push("loaded".to_owned());
57    } else if model.status.value != "unloaded" {
58        details.push(model.status.value.clone());
59    }
60    if loaded && let Some(context) = context_label(model) {
61        details.push(format!("{context} context"));
62    }
63    details.join(" · ")
64}
65
66fn row(model: &ModelInfo) -> String {
67    let detail = description(model);
68    if detail.is_empty() {
69        model.id.clone()
70    } else {
71        format!("{}  {detail}", model.id)
72    }
73}
74
75/// pi's `compactCount`.
76fn compact_count(value: u64) -> String {
77    let value = value as f64;
78    if value >= 1_000_000.0 {
79        let digits = usize::from(value < 10_000_000.0);
80        format!("{}M", yapi_types::js::to_fixed(value / 1_000_000.0, digits))
81    } else if value >= 1_000.0 {
82        let digits = usize::from(value < 100_000.0);
83        format!("{}k", yapi_types::js::to_fixed(value / 1_000.0, digits))
84    } else {
85        value.to_string()
86    }
87}
88
89/// A connection failure reads as pi's "Could not connect to the server.".
90fn connection_message(error: &str) -> String {
91    let lower = error.to_lowercase();
92    if lower.contains("fetch failed") || lower.contains("timeout") || lower.contains("network") {
93        "Could not connect to the server.".into()
94    } else {
95        error.to_owned()
96    }
97}
98
99fn is_connection_error(error: &str) -> bool {
100    connection_message(error) != error
101}
102
103fn progress_text(title: &str, model: &str, progress: &Progress) -> String {
104    let mut text = format!("{title} {model}: {}", progress.message);
105    if let Some(ratio) = progress.ratio {
106        text.push_str(&format!(" {}%", yapi_types::js::round(ratio * 100.0)));
107    }
108    if let Some(detail) = &progress.detail {
109        text.push_str(&format!(" ({detail})"));
110    }
111    text
112}
113
114struct Manager {
115    ctx_ui: Arc<dyn ExtensionUi>,
116    session: crate::agent_session::AgentSession,
117    client: Client,
118    cancel: CancellationToken,
119}
120
121impl Manager {
122    /// The catalog, after the session's llama.cpp models follow it.
123    async fn sync(&self) -> Result<Vec<ModelInfo>, String> {
124        let catalog = self.client.list(false, &self.cancel).await?;
125        self.refresh().await?;
126        Ok(catalog)
127    }
128
129    async fn refresh(&self) -> Result<(), String> {
130        // /llama already reached the server, so it refreshes even offline.
131        let result = self
132            .session
133            .refresh_model_catalogs(RefreshOptions {
134                providers: Some(vec![llama::PROVIDER_ID.into()]),
135                allow_network: true,
136                cancel: yapi_ai::model_catalog::cancel_after(
137                    yapi_ai::model_catalog::REFRESH_TIMEOUT,
138                ),
139                ..RefreshOptions::default()
140            })
141            .await;
142        if result.aborted {
143            return Err("Model catalog refresh timed out.".into());
144        }
145        match result.errors.into_iter().next() {
146            Some((_, error)) => Err(error),
147            None => Ok(()),
148        }
149    }
150
151    /// The catalog, offering a retry while the server is unreachable.
152    async fn read_catalog(&self) -> Option<Vec<ModelInfo>> {
153        loop {
154            match self.sync().await {
155                Ok(catalog) => return Some(catalog),
156                Err(error) => {
157                    let title = format!(
158                        "llama.cpp server unavailable\n{}\n\n{}",
159                        self.client.server_url,
160                        connection_message(&error)
161                    );
162                    let choice = self
163                        .ctx_ui
164                        .select(
165                            &title,
166                            vec!["Retry".into(), "Close".into()],
167                            DialogOptions::default(),
168                        )
169                        .await;
170                    if choice.as_deref() != Some("Retry") {
171                        return None;
172                    }
173                }
174            }
175        }
176    }
177
178    fn status(&self, text: Option<&str>) {
179        self.ctx_ui.set_status(STATUS, text);
180    }
181
182    async fn load(&self, catalog: &[ModelInfo], target: &ModelInfo) -> Result<(), String> {
183        let loaded: Vec<&ModelInfo> = catalog
184            .iter()
185            .filter(|model| model.id != target.id && model.is_loaded())
186            .collect();
187        let mut replace = false;
188        if !loaded.is_empty() {
189            let title = format!(
190                "{} model{} loaded",
191                loaded.len(),
192                if loaded.len() == 1 { " is" } else { "s are" }
193            );
194            let choice = self
195                .ctx_ui
196                .select(
197                    &title,
198                    vec![
199                        "Unload all and load".into(),
200                        "Keep loaded and load".into(),
201                        "Cancel".into(),
202                    ],
203                    DialogOptions::default(),
204                )
205                .await;
206            match choice.as_deref() {
207                Some("Unload all and load") => replace = true,
208                Some("Keep loaded and load") => {}
209                _ => return Ok(()),
210            }
211        }
212        if replace {
213            for model in &loaded {
214                self.client.unload_and_wait(&model.id, &self.cancel).await?;
215            }
216        }
217        let ui = Arc::clone(&self.ctx_ui);
218        let id = target.id.clone();
219        let report = move |progress: Progress| {
220            ui.set_status(STATUS, Some(&progress_text("Loading", &id, &progress)));
221        };
222        let result = self
223            .client
224            .load_and_wait(&target.id, &report, &self.cancel)
225            .await;
226        self.status(None);
227        if let Err(error) = result {
228            if replace {
229                self.ctx_ui
230                    .notify("Restoring previously loaded models", NotifyKind::Info);
231                for model in &loaded {
232                    let _ = self
233                        .client
234                        .load_and_wait(&model.id, &|_| {}, &self.cancel)
235                        .await;
236                }
237                let _ = self.sync().await;
238            }
239            return Err(error);
240        }
241        let refreshed = self.sync().await?;
242        let now_loaded = refreshed
243            .iter()
244            .any(|model| model.id == target.id && model.status.value == "loaded");
245        self.ctx_ui.notify(
246            &if now_loaded {
247                format!("Loaded {}", target.id)
248            } else {
249                format!("Load started for {}", target.id)
250            },
251            NotifyKind::Info,
252        );
253        Ok(())
254    }
255
256    async fn unload(&self, model: &ModelInfo) -> Result<(), String> {
257        if !self
258            .ctx_ui
259            .confirm("Unload model?", &model.id, DialogOptions::default())
260            .await
261        {
262            return Ok(());
263        }
264        self.client.unload_and_wait(&model.id, &self.cancel).await?;
265        self.sync().await?;
266        self.ctx_ui
267            .notify(&format!("Unloaded {}", model.id), NotifyKind::Info);
268        Ok(())
269    }
270
271    async fn download(&self) -> Result<(), String> {
272        let home = crate::tools::path::home_dir();
273        let token = huggingface::find_token(&|name| std::env::var(name).ok(), Some(home));
274        let hub = huggingface::Client::new(token, None);
275        let Some(query) = self
276            .ctx_ui
277            .input(
278                "Download a model from Hugging Face",
279                Some("Model name or owner/repository[:quant]"),
280                DialogOptions::default(),
281            )
282            .await
283            .map(|query| query.trim().to_owned())
284            .filter(|query| !query.is_empty())
285        else {
286            return Ok(());
287        };
288        // `owner/repository[:quant]` names a model. Anything else searches.
289        let selected = if query.contains('/') {
290            query
291        } else {
292            let results = hub.search(&query, &self.cancel).await?;
293            if results.is_empty() {
294                self.ctx_ui.notify(
295                    &format!("No GGUF models match \"{query}\""),
296                    NotifyKind::Warning,
297                );
298                return Ok(());
299            }
300            let labels: Vec<String> = results
301                .iter()
302                .map(|model| format!("{}  {} downloads", model.id, compact_count(model.downloads)))
303                .collect();
304            let Some(choice) = self
305                .ctx_ui
306                .select(
307                    "Hugging Face models",
308                    labels.clone(),
309                    DialogOptions::default(),
310                )
311                .await
312            else {
313                return Ok(());
314            };
315            let Some(index) = labels.iter().position(|label| *label == choice) else {
316                return Ok(());
317            };
318            results[index].id.clone()
319        };
320        let (repository, mut quantization) = match selected.find('/').and_then(|slash| {
321            selected[slash + 1..]
322                .find(':')
323                .map(|colon| slash + 1 + colon)
324        }) {
325            Some(colon) => (
326                selected[..colon].to_owned(),
327                Some(selected[colon + 1..].to_owned()),
328            ),
329            None => (selected.clone(), None),
330        };
331        self.status(Some(&format!("Loading model details: {repository}")));
332        let details = hub.details(&repository, &self.cancel).await;
333        self.status(None);
334        let details = details?;
335        if let Some(gated) = &details.gated {
336            let approval = if gated == "manual" {
337                "Manual approval is required"
338            } else {
339                "Accept the access terms"
340            };
341            let title = format!(
342                "Hugging Face access required\n{}\n\n{approval} at:\nhttps://huggingface.co/{}\n\nThe llama.cpp server needs HF_TOKEN with access.",
343                details.id, details.id
344            );
345            let choice = self
346                .ctx_ui
347                .select(
348                    &title,
349                    vec!["Continue".into(), "Back".into()],
350                    DialogOptions::default(),
351                )
352                .await;
353            if choice.as_deref() != Some("Continue") {
354                return Ok(());
355            }
356        }
357        if quantization.is_none() && !details.quantizations.is_empty() {
358            let options: Vec<String> = details
359                .quantizations
360                .iter()
361                .map(|entry| {
362                    let detail: Vec<String> = [
363                        entry.size.map(|size| llama::format_bytes(size as f64)),
364                        (entry.name == "Q4_K_M").then(|| "recommended".to_owned()),
365                    ]
366                    .into_iter()
367                    .flatten()
368                    .collect();
369                    if detail.is_empty() {
370                        entry.name.clone()
371                    } else {
372                        format!("{} · {}", entry.name, detail.join(" · "))
373                    }
374                })
375                .collect();
376            let Some(choice) = self
377                .ctx_ui
378                .select(
379                    &format!("Select quantization\n{}", details.id),
380                    options.clone(),
381                    DialogOptions::default(),
382                )
383                .await
384            else {
385                return Ok(());
386            };
387            let Some(index) = options.iter().position(|option| *option == choice) else {
388                return Ok(());
389            };
390            quantization = Some(details.quantizations[index].name.clone());
391        }
392        let model = match quantization {
393            Some(quantization) => format!("{}:{quantization}", details.id),
394            None => details.id.clone(),
395        };
396        let ui = Arc::clone(&self.ctx_ui);
397        let id = model.clone();
398        let report = move |progress: Progress| {
399            ui.set_status(STATUS, Some(&progress_text("Downloading", &id, &progress)));
400        };
401        let result = self
402            .client
403            .download_and_wait(&model, &report, &self.cancel)
404            .await;
405        self.status(None);
406        result?;
407        self.refresh().await?;
408        self.ctx_ui
409            .notify(&format!("Downloaded {model}"), NotifyKind::Info);
410        Ok(())
411    }
412}
413
414impl LlamaExtension {
415    async fn manage(&self, ctx: &Context) {
416        if ctx.mode != Mode::Tui {
417            ctx.ui.notify(
418                "/llama is available in interactive mode",
419                NotifyKind::Warning,
420            );
421            return;
422        }
423        let Some(session) = ctx.session.upgrade() else {
424            return;
425        };
426        let Some((server_url, api_key)) = session.registry().llama_server().await else {
427            ctx.ui.notify(
428                &format!("Configure llama.cpp with /login {}", llama::PROVIDER_ID),
429                NotifyKind::Warning,
430            );
431            return;
432        };
433        let client = match Client::new(&server_url, api_key) {
434            Ok(client) => client,
435            Err(error) => {
436                ctx.ui.notify(&error, NotifyKind::Error);
437                return;
438            }
439        };
440        let manager = Manager {
441            ctx_ui: Arc::clone(&ctx.ui),
442            session,
443            client,
444            cancel: CancellationToken::new(),
445        };
446        let Some(mut catalog) = manager.read_catalog().await else {
447            return;
448        };
449        loop {
450            let mut options: Vec<String> = catalog.iter().map(row).collect();
451            options.push(DOWNLOAD.into());
452            let title = format!("llama.cpp models\n{}", manager.client.server_url);
453            let Some(choice) = ctx
454                .ui
455                .select(&title, options.clone(), DialogOptions::default())
456                .await
457            else {
458                return;
459            };
460            let action = if choice == DOWNLOAD {
461                manager.download().await
462            } else {
463                match options
464                    .iter()
465                    .position(|option| *option == choice)
466                    .and_then(|index| catalog.get(index))
467                {
468                    Some(model) if model.is_loaded() => manager.unload(model).await,
469                    Some(model) if model.status.value == "unloaded" => {
470                        manager.load(&catalog, model).await
471                    }
472                    Some(model) => {
473                        ctx.ui.notify(
474                            &format!("{} is {}", model.id, model.status.value),
475                            NotifyKind::Warning,
476                        );
477                        Ok(())
478                    }
479                    None => Ok(()),
480                }
481            };
482            let Some(refreshed) = manager.read_catalog().await else {
483                return;
484            };
485            catalog = refreshed;
486            if let Err(error) = action
487                && !is_connection_error(&error)
488            {
489                ctx.ui.notify(&error, NotifyKind::Error);
490            }
491        }
492    }
493}
494
495impl Extension for LlamaExtension {
496    fn source(&self) -> SourceInfo {
497        builtin_source(NAME)
498    }
499
500    fn commands(&self) -> Vec<Command> {
501        vec![Command {
502            name: "llama".into(),
503            description: "Manage llama.cpp router models".into(),
504        }]
505    }
506
507    fn run_command<'a>(
508        &'a self,
509        command: &'a str,
510        _args: &'a str,
511        ctx: &'a Context,
512    ) -> BoxFuture<'a, ()> {
513        Box::pin(async move {
514            if command == "llama" {
515                self.manage(ctx).await;
516            }
517        })
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn describes_models_like_pi() {
527        let model: ModelInfo = serde_json::from_value(serde_json::json!({
528            "id": "qwen", "status": {"value": "loaded"}, "meta": {"n_ctx": 32768}
529        }))
530        .unwrap();
531        assert_eq!(row(&model), "qwen  loaded · 33k context");
532        let downloading: ModelInfo = serde_json::from_value(serde_json::json!({
533            "id": "big", "status": {"value": "downloading"}
534        }))
535        .unwrap();
536        assert_eq!(row(&downloading), "big  downloading");
537        assert_eq!(compact_count(1_234_567), "1.2M");
538        assert_eq!(compact_count(250_000), "250k");
539        assert_eq!(compact_count(999), "999");
540        assert_eq!(
541            connection_message("fetch failed"),
542            "Could not connect to the server."
543        );
544    }
545}