1use 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
20pub const NAME: &str = "llama.cpp";
22const STATUS: &str = "llama.cpp";
23const DOWNLOAD: &str = "Download a model…";
24
25#[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
51fn 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
75fn 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
89fn 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 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 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 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 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}