From 416942e4302d0fc2de33ac564c0a6f3dbf0d0e15 Mon Sep 17 00:00:00 2001 From: jh-block Date: Wed, 27 May 2026 20:00:30 +0200 Subject: [PATCH] local inference: stricter GGUF requirements, auto detection of tool calling support, fixed thinking output parsing (#9442) Signed-off-by: jh-block --- Cargo.lock | 1 + crates/goose-cli/src/cli.rs | 35 +- crates/goose-server/src/openapi.rs | 3 + .../src/routes/local_inference.rs | 193 +++++-- crates/goose/Cargo.toml | 2 + crates/goose/src/providers/base.rs | 13 + crates/goose/src/providers/local_inference.rs | 49 +- .../providers/local_inference/hf_models.rs | 182 ++++++- .../llamacpp/inference_emulated_tools.rs | 182 +++++-- .../llamacpp/inference_engine.rs | 277 +++++++++- .../llamacpp/inference_native_tools.rs | 210 +++----- .../providers/local_inference/llamacpp/mod.rs | 486 +++++++++++++++--- .../local_inference/local_model_registry.rs | 131 ++--- ui/desktop/openapi.json | 100 +++- ui/desktop/src/api/index.ts | 4 +- ui/desktop/src/api/sdk.gen.ts | 4 +- ui/desktop/src/api/types.gen.ts | 34 +- .../localInference/ModelSettingsPanel.tsx | 215 +++++++- ui/desktop/src/i18n/messages/en.json | 47 +- ui/desktop/src/i18n/messages/ru.json | 51 +- ui/desktop/src/i18n/messages/tr.json | 51 +- ui/desktop/src/i18n/messages/zh-CN.json | 51 +- ui/desktop/src/types/message.ts | 15 - 23 files changed, 1844 insertions(+), 492 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3e267cfb6..380f842ee9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4497,6 +4497,7 @@ dependencies = [ "keyring", "libc", "llama-cpp-2", + "llama-cpp-sys-2", "lru", "minijinja", "mockall", diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 0615d8ac38..78c374db33 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -1816,7 +1816,7 @@ async fn handle_term_subcommand(command: TermCommand) -> Result<()> { async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> { use goose::providers::local_inference::hf_models; use goose::providers::local_inference::local_model_registry::{ - get_registry, model_id_from_repo, LocalModelEntry, + get_registry, mmproj_local_path, model_id_from_repo, LocalModelEntry, }; match command { @@ -1853,10 +1853,28 @@ async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> } LocalModelsCommand::Download { spec } => { println!("Resolving {}...", spec); - let (repo_id, file) = hf_models::resolve_model_spec(&spec).await?; + let (repo_id, resolved) = hf_models::resolve_model_spec_full(&spec).await?; + if resolved.files.len() > 1 { + anyhow::bail!( + "Model '{}' is sharded ({} files) — download it from the desktop UI", + spec, + resolved.files.len() + ); + } + let mmproj = resolved.mmproj; + let file = resolved.files.into_iter().next().unwrap(); let model_id = model_id_from_repo(&repo_id, &file.quantization); let local_path = goose::config::paths::Paths::in_data_dir("models").join(&file.filename); + let mmproj_path = mmproj + .as_ref() + .map(|mmproj| mmproj_local_path(&repo_id, &mmproj.filename)); + let mmproj_source_url = mmproj.as_ref().map(|mmproj| mmproj.download_url.clone()); + let mmproj_size_bytes = mmproj.as_ref().map_or(0, |mmproj| mmproj.size_bytes); + let mut download_files = vec![(file.download_url.clone(), local_path.clone())]; + if let Some(mmproj) = mmproj { + download_files.push((mmproj.download_url, mmproj_path.clone().unwrap())); + } println!( "Downloading {} ({})...", @@ -1881,9 +1899,10 @@ async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> source_url: file.download_url.clone(), settings: Default::default(), size_bytes: file.size_bytes, - mmproj_path: None, - mmproj_source_url: None, - mmproj_size_bytes: 0, + mmproj_path, + mmproj_source_url, + mmproj_size_bytes, + mmproj_checked: true, shard_files: vec![], }; @@ -1897,10 +1916,10 @@ async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> // Download let manager = goose::download_manager::get_download_manager(); manager - .download_model( + .download_model_sharded( format!("{}-model", model_id), - file.download_url, - local_path, + download_files, + file.size_bytes + mmproj_size_bytes, None, ) .await?; diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 79d2301b87..5749b6440a 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -683,6 +683,7 @@ pub struct ApiDoc; super::routes::local_inference::list_local_models, super::routes::local_inference::sync_featured_models, super::routes::local_inference::search_hf_models, + super::routes::local_inference::list_builtin_chat_templates, super::routes::local_inference::get_repo_files, super::routes::local_inference::download_hf_model, super::routes::local_inference::get_local_model_download_progress, @@ -701,7 +702,9 @@ pub struct ApiDoc; goose::providers::local_inference::hf_models::HfQuantVariant, super::routes::local_inference::RepoVariantsResponse, goose::providers::local_inference::local_model_registry::ModelSettings, + goose::providers::local_inference::local_model_registry::ChatTemplate, goose::providers::local_inference::local_model_registry::SamplingConfig, + goose::providers::local_inference::local_model_registry::ToolCallingMode, )) )] pub struct LocalInferenceApiDoc; diff --git a/crates/goose-server/src/routes/local_inference.rs b/crates/goose-server/src/routes/local_inference.rs index b7a7dfbde6..8ee9bc4eeb 100644 --- a/crates/goose-server/src/routes/local_inference.rs +++ b/crates/goose-server/src/routes/local_inference.rs @@ -13,10 +13,10 @@ use goose::config::paths::Paths; use goose::download_manager::{get_download_manager, DownloadProgress}; use goose::providers::local_inference::hf_models::{self, HfModelInfo, HfQuantVariant}; use goose::providers::local_inference::{ - available_inference_memory_bytes, - hf_models::{resolve_model_spec, resolve_model_spec_full, HfGgufFile}, + available_inference_memory_bytes, builtin_chat_template_names, + hf_models::{resolve_model_spec_full, HfGgufFile}, local_model_registry::{ - default_settings_for_model, featured_mmproj_spec, get_registry, is_featured_model, + default_settings_for_model, get_registry, is_featured_model, mmproj_local_path, model_id_from_repo, LocalModelEntry, ModelDownloadStatus as RegistryDownloadStatus, ModelSettings, ShardFile, FEATURED_MODELS, }, @@ -79,26 +79,18 @@ async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> { .lock() .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; if let Some(existing) = registry.get_model(&model_id) { - let needs_backfill = existing.mmproj_path.is_none() && featured.mmproj.is_some(); - let needs_download = existing.is_downloaded() - && featured.mmproj.is_some() - && !existing.mmproj_path.as_ref().is_some_and(|p| p.exists()); - - if needs_download { - if let Some(mmproj) = featured.mmproj.as_ref() { - let path = mmproj.local_path(); - let url = format!( - "https://huggingface.co/{}/resolve/main/{}", - mmproj.repo, mmproj.filename - ); - mmproj_downloads_needed.push((model_id.clone(), url, path)); + if let Some(path) = &existing.mmproj_path { + if existing.is_downloaded() && !path.exists() { + if let Some(url) = &existing.mmproj_source_url { + mmproj_downloads_needed.push(( + model_id.clone(), + url.clone(), + path.clone(), + )); + } } - } - - if !needs_backfill { continue; } - // Fall through to resolve for backfill } } @@ -110,36 +102,45 @@ async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> { }); } - let resolved: Vec<(PendingResolve, HfGgufFile)> = + let resolved: Vec<(PendingResolve, HfGgufFile, Option)> = join_all(to_resolve.into_iter().map(|pending| async move { - let hf_file = match resolve_model_spec(pending.spec).await { - Ok((_repo, file)) => file, + let (hf_file, mmproj) = match resolve_model_spec_full(pending.spec).await { + Ok((_repo, resolved)) => (resolved.files[0].clone(), resolved.mmproj), Err(_) => { let filename = format!( "{}-{}.gguf", pending.repo_id.split('/').next_back().unwrap_or("model"), pending.quantization ); - HfGgufFile { - filename: filename.clone(), - size_bytes: 0, - quantization: pending.quantization.to_string(), - download_url: format!( - "https://huggingface.co/{}/resolve/main/{}", - pending.repo_id, filename - ), - } + ( + HfGgufFile { + filename: filename.clone(), + size_bytes: 0, + quantization: pending.quantization.to_string(), + download_url: format!( + "https://huggingface.co/{}/resolve/main/{}", + pending.repo_id, filename + ), + }, + None, + ) } }; - (pending, hf_file) + (pending, hf_file, mmproj) })) .await; let entries_to_add: Vec = resolved .into_iter() - .map(|(pending, hf_file)| { + .map(|(pending, hf_file, mmproj)| { let local_path = Paths::in_data_dir("models").join(&hf_file.filename); let settings = default_settings_for_model(&pending.model_id); + let mmproj_path = mmproj + .as_ref() + .map(|mmproj| mmproj_local_path(&pending.repo_id, &mmproj.filename)); + let mmproj_source_url = mmproj.as_ref().map(|mmproj| mmproj.download_url.clone()); + let mmproj_size_bytes = mmproj.as_ref().map_or(0, |mmproj| mmproj.size_bytes); + let mmproj_checked = mmproj.is_some(); LocalModelEntry { id: pending.model_id, repo_id: pending.repo_id, @@ -149,9 +150,10 @@ async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> { source_url: hf_file.download_url, settings, size_bytes: hf_file.size_bytes, - mmproj_path: None, - mmproj_source_url: None, - mmproj_size_bytes: 0, + mmproj_path, + mmproj_source_url, + mmproj_size_bytes, + mmproj_checked, shard_files: vec![], } }) @@ -165,20 +167,80 @@ async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> { if !entries_to_add.is_empty() { registry.sync_with_featured(entries_to_add); } + } + + let to_backfill: Vec<(String, String, String)> = { + let registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + + registry + .list_models() + .iter() + .filter(|model| model.is_downloaded()) + .filter(|model| model.mmproj_path.is_none()) + .filter(|model| !model.mmproj_checked) + .map(|model| { + ( + model.id.clone(), + model.repo_id.clone(), + model.quantization.clone(), + ) + }) + .collect() + }; + + let mmproj_backfills: Vec<(String, String, Option>)> = join_all( + to_backfill + .into_iter() + .map(|(id, repo_id, quantization)| async move { + let spec = format!("{repo_id}:{quantization}"); + let mmproj = resolve_model_spec_full(&spec) + .await + .ok() + .map(|(_, resolved)| resolved.mmproj); + (id, repo_id, mmproj) + }), + ) + .await; + + { + let mut registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + + for (model_id, repo_id, mmproj_result) in mmproj_backfills { + if let Some(model) = registry + .list_models_mut() + .iter_mut() + .find(|model| model.id == model_id) + { + let Some(mmproj) = mmproj_result else { + continue; + }; + + model.mmproj_checked = true; + if let Some(mmproj) = mmproj { + model.mmproj_path = Some(mmproj_local_path(&repo_id, &mmproj.filename)); + model.mmproj_source_url = Some(mmproj.download_url); + model.mmproj_size_bytes = mmproj.size_bytes; + } + model.refresh_mmproj_metadata(); + } + } - // Backfill mmproj data for all registry models and collect any - // needed mmproj downloads for models already on disk. for model in registry.list_models_mut() { - model.enrich_with_featured_mmproj(); + model.refresh_mmproj_metadata(); if model.is_downloaded() { - if let Some(mmproj) = featured_mmproj_spec(&model.id) { - let path = mmproj.local_path(); + if let Some(path) = &model.mmproj_path { if !path.exists() { - let url = format!( - "https://huggingface.co/{}/resolve/main/{}", - mmproj.repo, mmproj.filename - ); - mmproj_downloads_needed.push((model.id.clone(), url, path)); + if let Some(url) = &model.mmproj_source_url { + mmproj_downloads_needed.push(( + model.id.clone(), + url.clone(), + path.clone(), + )); + } } } } @@ -431,6 +493,20 @@ pub async fn download_hf_model( vec![] }; + let mmproj_path = resolved + .mmproj + .as_ref() + .map(|mmproj| mmproj_local_path(&repo_id, &mmproj.filename)); + let mmproj_source_url = resolved + .mmproj + .as_ref() + .map(|mmproj| mmproj.download_url.clone()); + let mmproj_size_bytes = resolved + .mmproj + .as_ref() + .map_or(0, |mmproj| mmproj.size_bytes); + let mmproj_checked = true; + let entry = LocalModelEntry { id: model_id.clone(), repo_id, @@ -440,13 +516,13 @@ pub async fn download_hf_model( source_url: first_file.download_url.clone(), settings: default_settings_for_model(&model_id), size_bytes: resolved.total_size, - mmproj_path: None, - mmproj_source_url: None, - mmproj_size_bytes: 0, + mmproj_path, + mmproj_source_url, + mmproj_size_bytes, + mmproj_checked, shard_files: shard_files.clone(), }; - // add_model enriches the entry with mmproj metadata from the featured table let mmproj_path = { let mut registry = get_registry() .lock() @@ -649,6 +725,17 @@ pub async fn update_model_settings( Ok(Json(settings)) } +#[utoipa::path( + get, + path = "/local-inference/chat-templates/builtin", + responses( + (status = 200, description = "llama.cpp built-in chat template names", body = Vec) + ) +)] +pub async fn list_builtin_chat_templates() -> Json> { + Json(builtin_chat_template_names()) +} + pub fn routes(state: Arc) -> Router { let registered_paths: std::collections::HashSet = get_registry() .lock() @@ -672,6 +759,10 @@ pub fn routes(state: Arc) -> Router { .route("/local-inference/models", get(list_local_models)) .route("/local-inference/sync-featured", post(sync_featured_models)) .route("/local-inference/search", get(search_hf_models)) + .route( + "/local-inference/chat-templates/builtin", + get(list_builtin_chat_templates), + ) .route( "/local-inference/repo/{author}/{repo}/files", get(get_repo_files), diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 7e391afcdb..fccd7dbbc6 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -25,6 +25,7 @@ local-inference = [ "dep:candle-nn", "dep:candle-transformers", "dep:llama-cpp-2", + "dep:llama-cpp-sys-2", "dep:tokenizers", "dep:symphonia", "dep:rubato", @@ -213,6 +214,7 @@ pctx_code_mode = { version = "0.3", default-features = false, optional = true } # They are just here to pin the version, and can be removed if PCTX updates temporal_rs icu_calendar = { version = "=2.1.1", default-features = false } icu_locale = { version = "=2.1.1", default-features = false } +llama-cpp-sys-2 = { workspace = true, optional = true } [target.'cfg(target_os = "windows")'.dependencies] winapi = { workspace = true } diff --git a/crates/goose/src/providers/base.rs b/crates/goose/src/providers/base.rs index 0737cbed6b..f79a3a3f3f 100644 --- a/crates/goose/src/providers/base.rs +++ b/crates/goose/src/providers/base.rs @@ -1419,6 +1419,19 @@ mod tests { assert_eq!(out.thinking, "unfinished"); } + #[test] + fn test_think_filter_tracks_generation_prompt_open_block() { + let mut filter = ThinkFilter::new(); + let _ = filter.push("<|assistant|>\n"); + let mut out = filter.push("hidden reasoningvisible answer"); + let final_out = filter.finish(); + out.content.push_str(&final_out.content); + out.thinking.push_str(&final_out.thinking); + + assert_eq!(out.content, "visible answer"); + assert_eq!(out.thinking, "hidden reasoning"); + } + #[test] fn test_think_filter_preserves_tags_with_think_prefix() { for input in [ diff --git a/crates/goose/src/providers/local_inference.rs b/crates/goose/src/providers/local_inference.rs index 970a35311f..e7229f5fdd 100644 --- a/crates/goose/src/providers/local_inference.rs +++ b/crates/goose/src/providers/local_inference.rs @@ -19,6 +19,7 @@ use async_trait::async_trait; use backend::{BackendLoadedModel, LocalInferenceBackend}; use futures::future::BoxFuture; use llamacpp::{LlamaCppBackend, LLAMACPP_BACKEND_ID}; +use local_model_registry::ChatTemplate; use rmcp::model::Tool; use serde_json::{json, Value}; use std::collections::HashMap; @@ -33,13 +34,19 @@ type ModelSlot = Arc>>>; struct ModelCacheKey { backend_id: &'static str, model_id: String, + chat_template: ChatTemplate, } impl ModelCacheKey { - fn new(backend_id: &'static str, model_id: impl Into) -> Self { + fn new( + backend_id: &'static str, + model_id: impl Into, + chat_template: ChatTemplate, + ) -> Self { Self { backend_id, model_id: model_id.into(), + chat_template, } } } @@ -49,6 +56,10 @@ pub struct InferenceRuntime { backends: HashMap<&'static str, Arc>, } +pub fn builtin_chat_template_names() -> Vec { + llamacpp::builtin_chat_template_names() +} + /// Global weak reference used to share a single `InferenceRuntime` across /// all providers and server routes. Only a `Weak` is stored — strong `Arc`s /// live in providers and `AppState`. When all strong refs drop (normal @@ -123,20 +134,12 @@ pub(super) struct ResolvedModelPaths { /// Resolve model path, context limit, settings, and mmproj path for a model ID from the registry. fn resolve_model_path(model_id: &str) -> Option { - use crate::providers::local_inference::local_model_registry::{ - default_settings_for_model, get_registry, - }; + use crate::providers::local_inference::local_model_registry::get_registry; if let Ok(registry) = get_registry().lock() { if let Some(entry) = registry.get_model(model_id) { let ctx = entry.settings.context_size.unwrap_or(0) as usize; let mut settings = entry.settings.clone(); - // Capability flags are inherent to the model family, not user-configurable. - // Re-derive them so that registry entries persisted before a model was - // recognized (or with a different quantization) still get the right behavior. - let defaults = default_settings_for_model(model_id); - settings.native_tool_calling = defaults.native_tool_calling; - settings.vision_capable = defaults.vision_capable; settings.mmproj_size_bytes = entry.mmproj_size_bytes; let mmproj_path = entry.mmproj_path.as_ref().filter(|p| p.exists()).cloned(); return Some(ResolvedModelPaths { @@ -195,6 +198,22 @@ fn build_openai_messages_json(system: &str, messages: &[Message]) -> String { serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string()) } +fn build_openai_text_messages_json(system: &str, messages: &[Message]) -> String { + let mut arr: Vec = vec![json!({"role": "system", "content": system})]; + arr.extend(messages.iter().filter_map(|m| { + let content = extract_text_content(m); + if content.trim().is_empty() { + return None; + } + let role = match m.role { + rmcp::model::Role::User => "user", + rmcp::model::Role::Assistant => "assistant", + }; + Some(json!({"role": role, "content": content})) + })); + serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string()) +} + /// Remove `image_url` content parts from OpenAI-format messages JSON, replacing /// each with a text note. This prevents an FFI crash in llama.cpp which does not /// accept `image_url` content-part types. @@ -422,7 +441,11 @@ impl Provider for LocalInferenceProvider { let backend = self.runtime.backend_for_model(&resolved)?; let model_context_limit = resolved.context_limit; let model_settings = resolved.settings.clone(); - let cache_key = ModelCacheKey::new(backend.id(), model_config.model_name.clone()); + let cache_key = ModelCacheKey::new( + backend.id(), + model_config.model_name.clone(), + model_settings.chat_template.clone(), + ); let model_slot = self.runtime.get_or_create_model_slot(cache_key.clone()); // Ensure model is loaded — unload any other models first to free memory. @@ -477,8 +500,8 @@ impl Provider for LocalInferenceProvider { }).collect::>(), "tools": tools.iter().map(|t| &t.name).collect::>(), "settings": { - "use_jinja": settings.use_jinja, - "native_tool_calling": settings.native_tool_calling, + "tool_calling": settings.tool_calling, + "chat_template": settings.chat_template, "context_size": settings.context_size, "sampling": settings.sampling, }, diff --git a/crates/goose/src/providers/local_inference/hf_models.rs b/crates/goose/src/providers/local_inference/hf_models.rs index 510cd63812..5767193ae2 100644 --- a/crates/goose/src/providers/local_inference/hf_models.rs +++ b/crates/goose/src/providers/local_inference/hf_models.rs @@ -42,6 +42,7 @@ pub struct HfQuantVariant { pub struct ResolvedModel { pub files: Vec, pub total_size: u64, + pub mmproj: Option, } #[derive(Debug, Deserialize)] @@ -183,6 +184,24 @@ fn parse_quantization(filename: &str) -> String { "unknown".to_string() } +fn quant_bits(quantization: &str) -> u8 { + let digits: String = quantization + .chars() + .skip_while(|c| !c.is_ascii_digit()) + .take_while(|c| c.is_ascii_digit()) + .collect(); + digits.parse().unwrap_or(0) +} + +fn mmproj_precision_preference(quantization: &str) -> u8 { + match quantization.to_uppercase().as_str() { + "BF16" => 3, + "F16" => 2, + "F32" => 1, + _ => 0, + } +} + fn looks_like_quant(s: &str) -> bool { let upper = s.to_uppercase(); upper.starts_with("Q") @@ -226,6 +245,64 @@ fn build_download_url(repo_id: &str, filename: &str) -> String { format!("{}/{}/resolve/main/{}", HF_DOWNLOAD_BASE, repo_id, filename) } +fn parent_components(filename: &str) -> Vec<&str> { + filename.rsplit_once('/').map_or(Vec::new(), |(parent, _)| { + parent.split('/').filter(|part| !part.is_empty()).collect() + }) +} + +fn is_prefix(prefix: &[&str], parts: &[&str]) -> bool { + prefix.len() <= parts.len() && prefix.iter().zip(parts).all(|(a, b)| a == b) +} + +fn select_best_mmproj( + repo_id: &str, + siblings: &[HfApiSibling], + model_filename: &str, + model_quantization: &str, +) -> Option { + let model_dir = parent_components(model_filename); + let model_bits = quant_bits(model_quantization); + + siblings + .iter() + .filter(|s| { + let lowercase = s.rfilename.to_lowercase(); + lowercase.ends_with(".gguf") && lowercase.contains("mmproj") + }) + .filter_map(|s| { + let mmproj_dir = parent_components(&s.rfilename); + if !is_prefix(&mmproj_dir, &model_dir) { + return None; + } + + let quantization = parse_quantization(&s.rfilename); + let bits = quant_bits(&quantization); + let diff = bits.abs_diff(model_bits); + let proximity = u8::MAX - diff; + + Some(( + mmproj_dir.len(), + proximity, + mmproj_precision_preference(&quantization), + s, + quantization, + )) + }) + .max_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + .then_with(|| b.3.rfilename.cmp(&a.3.rfilename)) + }) + .map(|(_, _, _, sibling, quantization)| HfGgufFile { + filename: sibling.rfilename.clone(), + size_bytes: sibling.size.unwrap_or(0), + quantization, + download_url: build_download_url(repo_id, &sibling.rfilename), + }) +} + /// Derive the expected model filename stem from a repo_id. /// e.g. "unsloth/gemma-4-26B-A4B-it-GGUF" → "gemma-4-26b-a4b-it" (lowercased) fn model_stem_from_repo(repo_id: &str) -> String { @@ -500,7 +577,7 @@ pub async fn resolve_model_spec_full(spec: &str) -> Result<(String, ResolvedMode // Collect all GGUF files matching the quantization let matching: Vec<_> = siblings - .into_iter() + .iter() .filter(|s| { s.rfilename.ends_with(".gguf") && is_model_file(&s.rfilename, &stem) @@ -519,7 +596,7 @@ pub async fn resolve_model_spec_full(spec: &str) -> Result<(String, ResolvedMode // Separate single files from shards let mut single_files: Vec<&HfApiSibling> = Vec::new(); let mut shard_files: Vec<&HfApiSibling> = Vec::new(); - for f in &matching { + for &f in &matching { if is_shard_file(&f.rfilename) { shard_files.push(f); } else { @@ -529,6 +606,7 @@ pub async fn resolve_model_spec_full(spec: &str) -> Result<(String, ResolvedMode // Prefer single file if available if let Some(single) = single_files.first() { + let mmproj = select_best_mmproj(&repo_id, &siblings, &single.rfilename, &quant); let file = HfGgufFile { filename: single.rfilename.clone(), size_bytes: single.size.unwrap_or(0), @@ -541,6 +619,7 @@ pub async fn resolve_model_spec_full(spec: &str) -> Result<(String, ResolvedMode ResolvedModel { files: vec![file], total_size, + mmproj, }, )); } @@ -600,7 +679,16 @@ pub async fn resolve_model_spec_full(spec: &str) -> Result<(String, ResolvedMode .collect(); let total_size: u64 = files.iter().map(|f| f.size_bytes).sum(); - Ok((repo_id, ResolvedModel { files, total_size })) + let mmproj = select_best_mmproj(&repo_id, &siblings, &files[0].filename, &quant); + + Ok(( + repo_id, + ResolvedModel { + files, + total_size, + mmproj, + }, + )) } /// Resolve a model spec to a specific GGUF file from the repo. @@ -816,4 +904,92 @@ mod tests { assert_eq!(variants[1].quantization, "Q4_K_M"); assert_eq!(variants[2].quantization, "IQ1_S"); } + + #[test] + fn test_select_best_mmproj_prefers_closest_precision() { + let files = vec![ + HfApiSibling { + rfilename: "mmproj-F32.gguf".into(), + size: Some(3_000), + }, + HfApiSibling { + rfilename: "mmproj-BF16.gguf".into(), + size: Some(2_000), + }, + ]; + + let mmproj = + select_best_mmproj("someone/model-GGUF", &files, "model-Q4_K_M.gguf", "Q4_K_M") + .unwrap(); + + assert_eq!(mmproj.filename, "mmproj-BF16.gguf"); + assert_eq!(mmproj.quantization, "BF16"); + } + + #[test] + fn test_select_best_mmproj_prefers_bf16_over_f16_tie() { + let files = vec![ + HfApiSibling { + rfilename: "mmproj-F16.gguf".into(), + size: Some(2_000), + }, + HfApiSibling { + rfilename: "mmproj-BF16.gguf".into(), + size: Some(2_000), + }, + ]; + + let mmproj = + select_best_mmproj("someone/model-GGUF", &files, "model-Q8_0.gguf", "Q8_0").unwrap(); + + assert_eq!(mmproj.filename, "mmproj-BF16.gguf"); + } + + #[test] + fn test_select_best_mmproj_prefers_nearest_directory() { + let files = vec![ + HfApiSibling { + rfilename: "mmproj-BF16.gguf".into(), + size: Some(2_000), + }, + HfApiSibling { + rfilename: "Q4_K_M/mmproj-F32.gguf".into(), + size: Some(3_000), + }, + ]; + + let mmproj = select_best_mmproj( + "someone/model-GGUF", + &files, + "Q4_K_M/model-Q4_K_M.gguf", + "Q4_K_M", + ) + .unwrap(); + + assert_eq!(mmproj.filename, "Q4_K_M/mmproj-F32.gguf"); + } + + #[test] + fn test_select_best_mmproj_ignores_sibling_directories() { + let files = vec![ + HfApiSibling { + rfilename: "Q8_0/mmproj-BF16.gguf".into(), + size: Some(2_000), + }, + HfApiSibling { + rfilename: "mmproj-F32.gguf".into(), + size: Some(3_000), + }, + ]; + + let mmproj = select_best_mmproj( + "someone/model-GGUF", + &files, + "Q4_K_M/model-Q4_K_M.gguf", + "Q4_K_M", + ) + .unwrap(); + + assert_eq!(mmproj.filename, "mmproj-F32.gguf"); + } } diff --git a/crates/goose/src/providers/local_inference/llamacpp/inference_emulated_tools.rs b/crates/goose/src/providers/local_inference/llamacpp/inference_emulated_tools.rs index 1f18612a68..bcde004359 100644 --- a/crates/goose/src/providers/local_inference/llamacpp/inference_emulated_tools.rs +++ b/crates/goose/src/providers/local_inference/llamacpp/inference_emulated_tools.rs @@ -22,7 +22,6 @@ use crate::conversation::message::{Message, MessageContent}; use crate::providers::errors::ProviderError; -use llama_cpp_2::model::AddBos; use rmcp::model::{CallToolRequestParams, Tool}; use serde_json::json; use std::borrow::Cow; @@ -30,8 +29,8 @@ use uuid::Uuid; use super::super::{finalize_usage, StreamSender}; use super::inference_engine::{ - create_and_prefill_context, create_and_prefill_multimodal, generation_loop, - validate_and_compute_context, GenerationContext, TokenAction, + generation_loop, prepare_generation, GenerationContext, StopSuffixTrimmer, + ThinkingOutputFilter, TokenAction, }; const SHELL_TOOL: &str = "developer__shell"; @@ -355,56 +354,26 @@ fn send_emulator_action( pub(super) fn generate_with_emulated_tools( ctx: &mut GenerationContext<'_>, code_mode_enabled: bool, + oai_messages_json: &str, ) -> Result<(), ProviderError> { - // Use oaicompat variant — its C++ wrapper catches exceptions that would - // otherwise abort the process when other native libs disturb the C++ ABI. - let prompt = ctx - .loaded - .model - .apply_chat_template_with_tools_oaicompat( - &ctx.loaded.template, - ctx.chat_messages, - None, // no tools for emulated path - None, // no json_schema - true, // add_generation_prompt - ) - .map(|r| r.prompt) - .map_err(|e| { - ProviderError::ExecutionError(format!("Failed to apply chat template: {}", e)) - })?; - - let (mut llama_ctx, prompt_token_count, effective_ctx) = if !ctx.images.is_empty() { - create_and_prefill_multimodal( - ctx.loaded, - ctx.backend, - &prompt, - ctx.images, - ctx.context_limit, - ctx.settings, - )? - } else { - let tokens = ctx - .loaded - .model - .str_to_token(&prompt, AddBos::Never) - .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; - let (ptc, ectx) = validate_and_compute_context( - ctx.loaded, - ctx.backend, - tokens.len(), - ctx.context_limit, - ctx.settings, - )?; - let lctx = - create_and_prefill_context(ctx.loaded, ctx.backend, &tokens, ectx, ctx.settings)?; - (lctx, ptc, ectx) - }; + let prepared = prepare_generation(ctx, oai_messages_json, None, None)?; + let template_result = prepared.template_result; + let mut llama_ctx = prepared.llama_ctx; + let prompt_token_count = prepared.prompt_token_count; + let effective_ctx = prepared.effective_ctx; let message_id = ctx.message_id; let tx = ctx.tx; let mut emulator_parser = StreamingEmulatorParser::new(code_mode_enabled); + let mut output_filter = ThinkingOutputFilter::new( + ctx.settings.enable_thinking, + &template_result.generation_prompt, + ); + let mut stop_trimmer = StopSuffixTrimmer::new(&template_result.additional_stops); + let mut generated_text = String::new(); let mut tool_call_emitted = false; let mut send_failed = false; + let mut stop_string_emitted = false; let output_token_count = generation_loop( &ctx.loaded.model, @@ -413,7 +382,10 @@ pub(super) fn generate_with_emulated_tools( prompt_token_count, effective_ctx, |piece| { - let actions = emulator_parser.process_chunk(piece); + generated_text.push_str(piece); + let filtered = output_filter.push_text(piece); + let (content, stop_seen) = stop_trimmer.push(&filtered.content); + let actions = emulator_parser.process_chunk(&content); for action in actions { match send_emulator_action(&action, message_id, tx) { Ok(is_tool) => { @@ -429,12 +401,47 @@ pub(super) fn generate_with_emulated_tools( } if tool_call_emitted { Ok(TokenAction::Stop) + } else if stop_seen + || template_result + .additional_stops + .iter() + .any(|stop| generated_text.ends_with(stop)) + { + stop_string_emitted = true; + Ok(TokenAction::Stop) } else { Ok(TokenAction::Continue) } }, )?; + if !send_failed { + let filtered = output_filter.finish(); + if !filtered.thinking.is_empty() { + let mut message = Message::assistant().with_thinking(filtered.thinking, ""); + message.id = Some(message_id.to_string()); + send_failed = tx.blocking_send(Ok((Some(message), None))).is_err(); + } + if !send_failed { + let content = if stop_string_emitted { + String::new() + } else { + let (content, stop_seen) = stop_trimmer.push(&filtered.content); + let mut content = content; + if !stop_seen { + content.push_str(&stop_trimmer.finish()); + } + content + }; + for action in emulator_parser.process_chunk(&content) { + if send_emulator_action(&action, message_id, tx).is_err() { + send_failed = true; + break; + } + } + } + } + if !send_failed { for action in emulator_parser.flush() { if send_emulator_action(&action, message_id, tx).is_err() { @@ -474,6 +481,50 @@ mod tests { parse_chunks(&[input], code_mode) } + fn trim_chunks(chunks: &[&str], stops: &[String]) -> (String, bool) { + let mut trimmer = StopSuffixTrimmer::new(stops); + let mut output = String::new(); + let mut stopped = false; + + for chunk in chunks { + let (content, stop_seen) = trimmer.push(chunk); + output.push_str(&content); + if stop_seen { + stopped = true; + break; + } + } + + if !stopped { + output.push_str(&trimmer.finish()); + } + + (output, stopped) + } + + fn parse_with_seeded_thinking( + chunks: &[&str], + code_mode: bool, + ) -> (String, Vec) { + let mut output_filter = ThinkingOutputFilter::new(true, "<|assistant|>\n"); + let mut parser = StreamingEmulatorParser::new(code_mode); + let mut thinking = String::new(); + let mut actions = Vec::new(); + + for chunk in chunks { + let filtered = output_filter.push_text(chunk); + thinking.push_str(&filtered.thinking); + actions.extend(parser.process_chunk(&filtered.content)); + } + + let filtered = output_filter.finish(); + thinking.push_str(&filtered.thinking); + actions.extend(parser.process_chunk(&filtered.content)); + actions.extend(parser.flush()); + + (thinking, actions) + } + fn assert_text(action: &EmulatorAction, expected: &str) { match action { EmulatorAction::Text(t) => assert_eq!(t.trim(), expected.trim(), "text mismatch"), @@ -507,6 +558,24 @@ mod tests { } } + #[test] + fn stop_suffix_trimmer_strips_split_stop() { + let stops = vec!["<|eom_id|>".to_string()]; + let (content, stopped) = trim_chunks(&["The answer", "<|e", "om_id|>"], &stops); + + assert!(stopped); + assert_eq!(content, "The answer"); + } + + #[test] + fn stop_suffix_trimmer_flushes_partial_non_stop() { + let stops = vec!["<|eom_id|>".to_string()]; + let (content, stopped) = trim_chunks(&["Use the <", " symbol"], &stops); + + assert!(!stopped); + assert_eq!(content, "Use the < symbol"); + } + #[test] fn plain_text_no_tools() { let actions = parse_all("Hello, world!", false); @@ -667,6 +736,25 @@ mod tests { assert_shell(shells[0], "echo hello"); } + #[test] + fn thinking_seeded_from_generation_prompt_is_not_emulated_text() { + let (thinking, actions) = + parse_with_seeded_thinking(&["reasoning\n$ echo hidden\nThe answer."], false); + + assert_eq!(thinking.trim(), "reasoning\n$ echo hidden"); + assert!(actions + .iter() + .all(|action| matches!(action, EmulatorAction::Text(_)))); + let text: String = actions + .iter() + .filter_map(|action| match action { + EmulatorAction::Text(text) => Some(text.as_str()), + _ => None, + }) + .collect(); + assert_eq!(text.trim(), "The answer."); + } + #[test] fn execute_block_with_multiline_code() { let input = "```execute_typescript\nasync function run() {\n const r = await Developer.shell({ command: \"ls\" });\n return r;\n}\n```\n"; diff --git a/crates/goose/src/providers/local_inference/llamacpp/inference_engine.rs b/crates/goose/src/providers/local_inference/llamacpp/inference_engine.rs index 86d2988c05..2a19f5fe75 100644 --- a/crates/goose/src/providers/local_inference/llamacpp/inference_engine.rs +++ b/crates/goose/src/providers/local_inference/llamacpp/inference_engine.rs @@ -1,3 +1,4 @@ +use crate::providers::base::{FilterOut, ThinkFilter}; use crate::providers::errors::ProviderError; use crate::providers::local_inference::backend::LocalInferenceBackend; use crate::providers::local_inference::local_model_registry::ModelSettings; @@ -5,8 +6,9 @@ use crate::providers::local_inference::multimodal::ExtractedImage; use crate::providers::utils::RequestLog; use llama_cpp_2::context::params::LlamaContextParams; use llama_cpp_2::llama_batch::LlamaBatch; -use llama_cpp_2::model::{LlamaChatMessage, LlamaChatTemplate, LlamaModel}; +use llama_cpp_2::model::{AddBos, ChatTemplateResult, LlamaChatTemplate, LlamaModel}; use llama_cpp_2::mtmd::{MtmdBitmap, MtmdContext, MtmdInputText}; +use llama_cpp_2::openai::OpenAIChatTemplateParams; use llama_cpp_2::sampling::LlamaSampler; use std::num::NonZeroU32; @@ -16,7 +18,7 @@ use super::LlamaCppBackend; pub(super) struct GenerationContext<'a> { pub loaded: &'a LoadedModel, pub backend: &'a LlamaCppBackend, - pub chat_messages: &'a [LlamaChatMessage], + pub template: &'a LlamaChatTemplate, pub settings: &'a ModelSettings, pub context_limit: usize, pub model_name: String, @@ -28,11 +30,165 @@ pub(super) struct GenerationContext<'a> { pub(super) struct LoadedModel { pub model: LlamaModel, - pub template: LlamaChatTemplate, + pub templates: LoadedChatTemplates, /// Multimodal context for vision models. None for text-only models. pub mtmd_ctx: Option, } +pub(super) struct LoadedChatTemplates { + pub default: Option, + pub tool_use: Option, + pub force_default: bool, +} + +pub(super) struct PreparedGeneration<'model> { + pub template_result: ChatTemplateResult, + pub llama_ctx: llama_cpp_2::context::LlamaContext<'model>, + pub prompt_token_count: usize, + pub effective_ctx: usize, +} + +pub(super) struct ThinkingOutputFilter { + enabled: bool, + saw_structured_reasoning: bool, + think_filter: ThinkFilter, + pending_inline_thinking: String, + accumulated_thinking: String, +} + +impl ThinkingOutputFilter { + pub(super) fn new(enable_thinking: bool, generation_prompt: &str) -> Self { + let mut think_filter = ThinkFilter::new(); + if enable_thinking && !generation_prompt.is_empty() { + let _ = think_filter.push(generation_prompt); + } + + Self { + enabled: enable_thinking, + saw_structured_reasoning: false, + think_filter, + pending_inline_thinking: String::new(), + accumulated_thinking: String::new(), + } + } + + pub(super) fn push_structured_reasoning(&mut self, reasoning: &str) -> Option { + if reasoning.is_empty() { + return None; + } + + self.saw_structured_reasoning = true; + self.pending_inline_thinking.clear(); + self.think_filter = ThinkFilter::new(); + self.accumulated_thinking.push_str(reasoning); + Some(reasoning.to_string()) + } + + pub(super) fn push_text(&mut self, text: &str) -> FilterOut { + if !self.enabled { + return FilterOut { + content: text.to_string(), + thinking: String::new(), + }; + } + + let mut filtered = self.think_filter.push(text); + if self.saw_structured_reasoning { + filtered.thinking.clear(); + } else if !filtered.thinking.is_empty() { + self.pending_inline_thinking.push_str(&filtered.thinking); + filtered.thinking.clear(); + } + filtered + } + + pub(super) fn finish(&mut self) -> FilterOut { + let mut filtered = if self.enabled && !self.saw_structured_reasoning { + std::mem::take(&mut self.think_filter).finish() + } else { + FilterOut::default() + }; + + if !self.saw_structured_reasoning { + let mut thinking = std::mem::take(&mut self.pending_inline_thinking); + thinking.push_str(&filtered.thinking); + if !thinking.is_empty() { + self.accumulated_thinking.push_str(&thinking); + } + filtered.thinking = thinking; + } else { + filtered.thinking.clear(); + } + + filtered + } + + pub(super) fn accumulated_thinking(&self) -> &str { + &self.accumulated_thinking + } +} + +pub(super) struct StopSuffixTrimmer { + pending: String, + stops: Vec, +} + +impl StopSuffixTrimmer { + pub(super) fn new(stops: &[String]) -> Self { + Self { + pending: String::new(), + stops: stops + .iter() + .filter(|stop| !stop.is_empty()) + .cloned() + .collect(), + } + } + + pub(super) fn push(&mut self, chunk: &str) -> (String, bool) { + if self.stops.is_empty() { + return (chunk.to_string(), false); + } + + self.pending.push_str(chunk); + + if let Some(stop) = self + .stops + .iter() + .filter(|stop| self.pending.ends_with(stop.as_str())) + .max_by_key(|stop| stop.len()) + { + let emit_len = self.pending.len() - stop.len(); + let _stop = self.pending.split_off(emit_len); + let emit = std::mem::take(&mut self.pending); + return (emit, true); + } + + let hold_len = self + .pending + .char_indices() + .map(|(idx, _)| idx) + .chain(std::iter::once(self.pending.len())) + .filter(|idx| { + self.pending + .get(*idx..) + .is_some_and(|suffix| self.stops.iter().any(|stop| stop.starts_with(suffix))) + }) + .map(|idx| self.pending.len() - idx) + .max() + .unwrap_or(0); + + let emit_len = self.pending.len() - hold_len; + let keep = self.pending.split_off(emit_len); + let emit = std::mem::replace(&mut self.pending, keep); + (emit, false) + } + + pub(super) fn finish(&mut self) -> String { + std::mem::take(&mut self.pending) + } +} + /// Estimate the maximum context length that can fit in available accelerator/CPU /// memory based on the model's KV cache requirements. /// @@ -349,6 +505,121 @@ pub(super) fn create_and_prefill_multimodal<'model>( Ok((llama_ctx, prompt_token_count, effective_ctx)) } +pub(super) fn prepare_generation<'model>( + ctx: &mut GenerationContext<'model>, + oai_messages_json: &str, + full_tools_json: Option<&str>, + compact_tools_json: Option<&str>, +) -> Result, ProviderError> { + let apply_template = |tools: Option<&str>| { + let params = OpenAIChatTemplateParams { + messages_json: oai_messages_json, + tools_json: tools, + tool_choice: None, + json_schema: None, + grammar: None, + reasoning_format: if ctx.settings.enable_thinking { + Some("auto") + } else { + None + }, + chat_template_kwargs: None, + add_generation_prompt: true, + use_jinja: true, + parallel_tool_calls: false, + enable_thinking: ctx.settings.enable_thinking, + add_bos: false, + add_eos: false, + parse_tool_calls: true, + }; + ctx.loaded + .model + .apply_chat_template_oaicompat(ctx.template, ¶ms) + }; + + let min_generation_headroom = 512; + let n_ctx_train = ctx.loaded.model.n_ctx_train() as usize; + let mmproj_overhead = if ctx.loaded.mtmd_ctx.is_some() { + ctx.settings.mmproj_size_bytes + } else { + 0 + }; + let memory_max_ctx = + estimate_max_context_for_memory(&ctx.loaded.model, ctx.backend, mmproj_overhead); + let cap = context_cap(ctx.settings, ctx.context_limit, n_ctx_train, memory_max_ctx); + let token_budget = cap.saturating_sub(min_generation_headroom); + let estimated_image_tokens = ctx.images.len() * ctx.settings.image_token_estimate; + + let template_result = match apply_template(full_tools_json) { + Ok(r) => { + let token_count = ctx + .loaded + .model + .str_to_token(&r.prompt, AddBos::Never) + .map(|t| t.len()) + .unwrap_or(0); + if token_count + estimated_image_tokens > token_budget { + apply_template(compact_tools_json).unwrap_or(r) + } else { + r + } + } + Err(e) => { + tracing::warn!( + error = %e, + "Failed to apply llama.cpp OpenAI-compatible chat template" + ); + match apply_template(compact_tools_json) { + Ok(r) => r, + Err(compact_err) => { + return Err(ProviderError::ExecutionError(format!( + "Failed to apply chat template with llama.cpp's Jinja renderer. This usually means the selected built-in template name does not exist, the embedded or custom template is invalid, or the template is incompatible with the current message shape. Select a valid llama.cpp built-in template name, configure a custom inline Jinja template, or use a GGUF with valid tokenizer.chat_template metadata. Full tools error: {e}; compact tools error: {compact_err}" + ))); + } + } + } + }; + + let _ = ctx.log.write( + &serde_json::json!({"applied_prompt": &template_result.prompt}), + None, + ); + + let (llama_ctx, prompt_token_count, effective_ctx) = if !ctx.images.is_empty() { + create_and_prefill_multimodal( + ctx.loaded, + ctx.backend, + &template_result.prompt, + ctx.images, + ctx.context_limit, + ctx.settings, + )? + } else { + let tokens = ctx + .loaded + .model + .str_to_token(&template_result.prompt, AddBos::Never) + .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; + let (ptc, ectx) = validate_and_compute_context( + ctx.loaded, + ctx.backend, + tokens.len(), + ctx.context_limit, + ctx.settings, + )?; + let lctx = + create_and_prefill_context(ctx.loaded, ctx.backend, &tokens, ectx, ctx.settings)?; + (lctx, ptc, ectx) + }; + + Ok(PreparedGeneration { + template_result, + llama_ctx, + prompt_token_count, + effective_ctx, + }) +} + /// Action to take after processing a generated token piece. pub(super) enum TokenAction { Continue, diff --git a/crates/goose/src/providers/local_inference/llamacpp/inference_native_tools.rs b/crates/goose/src/providers/local_inference/llamacpp/inference_native_tools.rs index 662b802445..501ad6b849 100644 --- a/crates/goose/src/providers/local_inference/llamacpp/inference_native_tools.rs +++ b/crates/goose/src/providers/local_inference/llamacpp/inference_native_tools.rs @@ -1,7 +1,5 @@ use crate::conversation::message::{Message, MessageContent}; use crate::providers::errors::ProviderError; -use llama_cpp_2::model::AddBos; -use llama_cpp_2::openai::OpenAIChatTemplateParams; use rmcp::model::CallToolRequestParams; use serde_json::Value; use std::borrow::Cow; @@ -9,121 +7,27 @@ use uuid::Uuid; use super::super::finalize_usage; use super::inference_engine::{ - context_cap, create_and_prefill_context, create_and_prefill_multimodal, - estimate_max_context_for_memory, generation_loop, validate_and_compute_context, - GenerationContext, TokenAction, + generation_loop, prepare_generation, GenerationContext, StopSuffixTrimmer, + ThinkingOutputFilter, TokenAction, }; pub(super) fn generate_with_native_tools( ctx: &mut GenerationContext<'_>, - oai_messages_json: &Option, + oai_messages_json: &str, full_tools_json: Option<&str>, compact_tools: Option<&str>, ) -> Result<(), ProviderError> { - let min_generation_headroom = 512; - let n_ctx_train = ctx.loaded.model.n_ctx_train() as usize; - let mmproj_overhead = if ctx.loaded.mtmd_ctx.is_some() { - ctx.settings.mmproj_size_bytes - } else { - 0 - }; - let memory_max_ctx = - estimate_max_context_for_memory(&ctx.loaded.model, ctx.backend, mmproj_overhead); - let cap = context_cap(ctx.settings, ctx.context_limit, n_ctx_train, memory_max_ctx); - let token_budget = cap.saturating_sub(min_generation_headroom); - - let apply_template = |tools: Option<&str>| { - if let Some(ref messages_json) = oai_messages_json { - let params = OpenAIChatTemplateParams { - messages_json: messages_json.as_str(), - tools_json: tools, - tool_choice: None, - json_schema: None, - grammar: None, - reasoning_format: if ctx.settings.enable_thinking { - Some("auto") - } else { - None - }, - chat_template_kwargs: None, - add_generation_prompt: true, - use_jinja: true, - parallel_tool_calls: false, - enable_thinking: ctx.settings.enable_thinking, - add_bos: false, - add_eos: false, - parse_tool_calls: true, - }; - ctx.loaded - .model - .apply_chat_template_oaicompat(&ctx.loaded.template, ¶ms) - } else { - ctx.loaded.model.apply_chat_template_with_tools_oaicompat( - &ctx.loaded.template, - ctx.chat_messages, - tools, - None, - true, - ) - } - }; - - let estimated_image_tokens = ctx.images.len() * ctx.settings.image_token_estimate; - - let template_result = match apply_template(full_tools_json) { - Ok(r) => { - let token_count = ctx - .loaded - .model - .str_to_token(&r.prompt, AddBos::Never) - .map(|t| t.len()) - .unwrap_or(0); - if token_count + estimated_image_tokens > token_budget { - apply_template(compact_tools).unwrap_or(r) - } else { - r - } - } - Err(_) => apply_template(compact_tools).map_err(|e| { - ProviderError::ExecutionError(format!("Failed to apply chat template: {}", e)) - })?, - }; - - let _ = ctx.log.write( - &serde_json::json!({"applied_prompt": &template_result.prompt}), - None, - ); - - let (mut llama_ctx, prompt_token_count, effective_ctx) = if !ctx.images.is_empty() { - create_and_prefill_multimodal( - ctx.loaded, - ctx.backend, - &template_result.prompt, - ctx.images, - ctx.context_limit, - ctx.settings, - )? - } else { - let tokens = ctx - .loaded - .model - .str_to_token(&template_result.prompt, AddBos::Never) - .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; - let (ptc, ectx) = validate_and_compute_context( - ctx.loaded, - ctx.backend, - tokens.len(), - ctx.context_limit, - ctx.settings, - )?; - let lctx = - create_and_prefill_context(ctx.loaded, ctx.backend, &tokens, ectx, ctx.settings)?; - (lctx, ptc, ectx) - }; + let prepared = prepare_generation(ctx, oai_messages_json, full_tools_json, compact_tools)?; + let template_result = prepared.template_result; + let mut llama_ctx = prepared.llama_ctx; + let prompt_token_count = prepared.prompt_token_count; + let effective_ctx = prepared.effective_ctx; let message_id = ctx.message_id; let tx = ctx.tx; let mut generated_text = String::new(); + let mut stop_trimmer = StopSuffixTrimmer::new(&template_result.additional_stops); + let mut stop_string_emitted = false; // Initialize streaming parser — handles thinking tokens, tool calls, etc. let mut stream_parser = template_result.streaming_state_oaicompat().map_err(|e| { @@ -141,7 +45,10 @@ pub(super) fn generate_with_native_tools( // Accumulate thinking/reasoning across the entire generation so we can // attach it to the final tool-call message (mirroring what the OpenAI // streaming path does). Streaming chunks are still sent for UI display. - let mut accumulated_thinking = String::new(); + let mut output_filter = ThinkingOutputFilter::new( + ctx.settings.enable_thinking, + &template_result.generation_prompt, + ); let output_token_count = generation_loop( &ctx.loaded.model, @@ -151,6 +58,7 @@ pub(super) fn generate_with_native_tools( effective_ctx, |piece| { generated_text.push_str(piece); + let mut stop_seen = false; // Feed the new piece to the streaming parser match stream_parser.update(piece, true) { @@ -161,9 +69,10 @@ pub(super) fn generate_with_native_tools( if let Some(reasoning) = delta.get("reasoning_content").and_then(|v| v.as_str()) { - if !reasoning.is_empty() { - accumulated_thinking.push_str(reasoning); - let mut msg = Message::assistant().with_thinking(reasoning, ""); + if let Some(thinking) = + output_filter.push_structured_reasoning(reasoning) + { + let mut msg = Message::assistant().with_thinking(thinking, ""); msg.id = Some(message_id.to_string()); if tx.blocking_send(Ok((Some(msg), None))).is_err() { return Ok(TokenAction::Stop); @@ -173,10 +82,15 @@ pub(super) fn generate_with_native_tools( // Stream content text to the UI if let Some(content) = delta.get("content").and_then(|v| v.as_str()) { if !content.is_empty() { - let mut msg = Message::assistant().with_text(content); - msg.id = Some(message_id.to_string()); - if tx.blocking_send(Ok((Some(msg), None))).is_err() { - return Ok(TokenAction::Stop); + let filtered = output_filter.push_text(content); + let (content, seen) = stop_trimmer.push(&filtered.content); + stop_seen |= seen; + if !content.is_empty() { + let mut msg = Message::assistant().with_text(content); + msg.id = Some(message_id.to_string()); + if tx.blocking_send(Ok((Some(msg), None))).is_err() { + return Ok(TokenAction::Stop); + } } } } @@ -193,19 +107,26 @@ pub(super) fn generate_with_native_tools( } Err(e) => { tracing::warn!("Streaming parser error: {}", e); - let mut msg = Message::assistant().with_text(piece); - msg.id = Some(message_id.to_string()); - if tx.blocking_send(Ok((Some(msg), None))).is_err() { - return Ok(TokenAction::Stop); + let filtered = output_filter.push_text(piece); + let (content, seen) = stop_trimmer.push(&filtered.content); + stop_seen |= seen; + if !content.is_empty() { + let mut msg = Message::assistant().with_text(content); + msg.id = Some(message_id.to_string()); + if tx.blocking_send(Ok((Some(msg), None))).is_err() { + return Ok(TokenAction::Stop); + } } } } - let should_stop = template_result - .additional_stops - .iter() - .any(|stop| generated_text.ends_with(stop)); + let should_stop = stop_seen + || template_result + .additional_stops + .iter() + .any(|stop| generated_text.ends_with(stop)); if should_stop { + stop_string_emitted = true; Ok(TokenAction::Stop) } else { Ok(TokenAction::Continue) @@ -218,18 +139,22 @@ pub(super) fn generate_with_native_tools( for delta_json in final_deltas { if let Ok(delta) = serde_json::from_str::(&delta_json) { if let Some(reasoning) = delta.get("reasoning_content").and_then(|v| v.as_str()) { - if !reasoning.is_empty() { - accumulated_thinking.push_str(reasoning); - let mut msg = Message::assistant().with_thinking(reasoning, ""); + if let Some(thinking) = output_filter.push_structured_reasoning(reasoning) { + let mut msg = Message::assistant().with_thinking(thinking, ""); msg.id = Some(message_id.to_string()); let _ = tx.blocking_send(Ok((Some(msg), None))); } } if let Some(content) = delta.get("content").and_then(|v| v.as_str()) { if !content.is_empty() { - let mut msg = Message::assistant().with_text(content); - msg.id = Some(message_id.to_string()); - let _ = tx.blocking_send(Ok((Some(msg), None))); + let filtered = output_filter.push_text(content); + let (content, stop_seen) = stop_trimmer.push(&filtered.content); + stop_string_emitted |= stop_seen; + if !content.is_empty() { + let mut msg = Message::assistant().with_text(content); + msg.id = Some(message_id.to_string()); + let _ = tx.blocking_send(Ok((Some(msg), None))); + } } } if let Some(tool_calls) = delta.get("tool_calls").and_then(|v| v.as_array()) { @@ -241,6 +166,28 @@ pub(super) fn generate_with_native_tools( } } + let filtered = output_filter.finish(); + if !filtered.thinking.is_empty() { + let mut msg = Message::assistant().with_thinking(&filtered.thinking, ""); + msg.id = Some(message_id.to_string()); + let _ = tx.blocking_send(Ok((Some(msg), None))); + } + let content = if stop_string_emitted { + String::new() + } else { + let (content, stop_seen) = stop_trimmer.push(&filtered.content); + let mut content = content; + if !stop_seen { + content.push_str(&stop_trimmer.finish()); + } + content + }; + if !content.is_empty() { + let mut msg = Message::assistant().with_text(content); + msg.id = Some(message_id.to_string()); + let _ = tx.blocking_send(Ok((Some(msg), None))); + } + // Build a single message combining thinking + all tool calls, mirroring // the structure produced by the OpenAI streaming path. The agent relies // on this combined message to: @@ -250,8 +197,11 @@ pub(super) fn generate_with_native_tools( let tool_call_contents = extract_oai_tool_call_contents(&accumulated_tool_calls); if !tool_call_contents.is_empty() { let mut contents: Vec = Vec::new(); - if !accumulated_thinking.is_empty() { - contents.push(MessageContent::thinking(&accumulated_thinking, "")); + if !output_filter.accumulated_thinking().is_empty() { + contents.push(MessageContent::thinking( + output_filter.accumulated_thinking(), + "", + )); } contents.extend(tool_call_contents); let mut msg = Message::new( diff --git a/crates/goose/src/providers/local_inference/llamacpp/mod.rs b/crates/goose/src/providers/local_inference/llamacpp/mod.rs index 0b4141ad72..4e076f4593 100644 --- a/crates/goose/src/providers/local_inference/llamacpp/mod.rs +++ b/crates/goose/src/providers/local_inference/llamacpp/mod.rs @@ -3,35 +3,312 @@ mod inference_engine; mod inference_native_tools; use std::any::Any; +use std::ffi::CStr; use std::path::PathBuf; use anyhow::Result; use llama_cpp_2::llama_backend::LlamaBackend; use llama_cpp_2::model::params::LlamaModelParams; -use llama_cpp_2::model::{LlamaChatMessage, LlamaChatTemplate, LlamaModel}; +use llama_cpp_2::model::{ChatTemplateResult, LlamaChatTemplate, LlamaModel}; +use llama_cpp_2::openai::OpenAIChatTemplateParams; use llama_cpp_2::{list_llama_ggml_backend_devices, LlamaBackendDeviceType, LogOptions}; -use rmcp::model::Role; use self::inference_emulated_tools::{ build_emulator_tool_description, generate_with_emulated_tools, load_tiny_model_prompt, }; -use self::inference_engine::{GenerationContext, LoadedModel}; +use self::inference_engine::{GenerationContext, LoadedChatTemplates, LoadedModel}; use self::inference_native_tools::generate_with_native_tools; use crate::providers::errors::ProviderError; use crate::providers::formats::openai::format_tools; use crate::providers::local_inference::backend::{ BackendLoadedModel, LocalGenerationRequest, LocalInferenceBackend, }; +use crate::providers::local_inference::local_model_registry::{ + ChatTemplate, ModelSettings, ToolCallingMode, +}; use crate::providers::local_inference::multimodal::ExtractedImage; use crate::providers::local_inference::tool_parsing::compact_tools_json; use crate::providers::local_inference::{ - build_openai_messages_json, extract_text_content, ResolvedModelPaths, + build_openai_messages_json, build_openai_text_messages_json, ResolvedModelPaths, }; pub(super) const LLAMACPP_BACKEND_ID: &str = "llamacpp"; const CODE_EXECUTION_TOOL: &str = "code_execution__execute_typescript"; +pub(super) fn builtin_chat_template_names() -> Vec { + let count = unsafe { llama_cpp_sys_2::llama_chat_builtin_templates(std::ptr::null_mut(), 0) }; + if count <= 0 { + return Vec::new(); + } + + let mut templates = vec![std::ptr::null(); count as usize]; + let written = unsafe { + llama_cpp_sys_2::llama_chat_builtin_templates(templates.as_mut_ptr(), templates.len()) + }; + templates.truncate(written.max(0) as usize); + + templates + .into_iter() + .filter(|ptr| !ptr.is_null()) + .filter_map(|ptr| { + unsafe { CStr::from_ptr(ptr) } + .to_str() + .ok() + .map(str::to_string) + }) + .collect() +} + +fn template_result_supports_native_tool_calling(result: &ChatTemplateResult) -> bool { + result.parse_tool_calls + && result + .parser + .as_deref() + .is_some_and(|parser| !parser.trim().is_empty()) +} + +fn supports_native_tool_calling( + loaded: &LoadedModel, + settings: &ModelSettings, + template: &LlamaChatTemplate, + oai_messages_json: &str, + tools_json: Option<&str>, +) -> bool { + let Some(tools_json) = tools_json.filter(|tools| !tools.trim().is_empty()) else { + return false; + }; + + // llama.cpp exposes common_chat_templates_get_caps in C++, but llama-cpp-2 + // 0.1.146 does not bind it yet. Replace this dry-run with that capability + // map once it is available through the Rust wrapper. + let params = OpenAIChatTemplateParams { + messages_json: oai_messages_json, + tools_json: Some(tools_json), + tool_choice: None, + json_schema: None, + grammar: None, + reasoning_format: if settings.enable_thinking { + Some("auto") + } else { + None + }, + chat_template_kwargs: None, + add_generation_prompt: true, + use_jinja: true, + parallel_tool_calls: false, + enable_thinking: settings.enable_thinking, + add_bos: false, + add_eos: false, + parse_tool_calls: true, + }; + + match loaded + .model + .apply_chat_template_oaicompat(template, ¶ms) + { + Ok(result) => template_result_supports_native_tool_calling(&result), + Err(e) => { + tracing::debug!( + error = %e, + "llama.cpp chat template dry-run did not support native tool calling" + ); + false + } + } +} + +fn should_use_native_tool_calling( + mode: ToolCallingMode, + has_tools: bool, + template_supports_native: bool, +) -> bool { + has_tools + && match mode { + ToolCallingMode::Auto => template_supports_native, + ToolCallingMode::ForceNative => true, + ToolCallingMode::ForceEmulated => false, + } +} + +fn is_legacy_builtin_template_name(template: &str) -> bool { + matches!( + template.trim(), + "bailing" + | "bailing-think" + | "bailing2" + | "chatglm3" + | "chatglm4" + | "command-r" + | "deepseek" + | "deepseek-ocr" + | "deepseek2" + | "deepseek3" + | "exaone-moe" + | "exaone3" + | "exaone4" + | "falcon3" + | "gemma" + | "gigachat" + | "glmedge" + | "gpt-oss" + | "granite" + | "granite-4.0" + | "grok-2" + | "hunyuan-dense" + | "hunyuan-moe" + | "hunyuan-ocr" + | "kimi-k2" + | "llama2" + | "llama2-sys" + | "llama2-sys-bos" + | "llama2-sys-strip" + | "llama3" + | "llama4" + | "megrez" + | "minicpm" + | "mistral-v1" + | "mistral-v3" + | "mistral-v3-tekken" + | "mistral-v7" + | "mistral-v7-tekken" + | "monarch" + | "openchat" + | "orion" + | "pangu-embedded" + | "phi3" + | "phi4" + | "rwkv-world" + | "seed_oss" + | "smolvlm" + | "solar-open" + | "vicuna" + | "vicuna-orca" + | "yandex" + | "zephyr" + ) +} + +fn missing_chat_template_error( + model_id: &str, + architecture: Option<&str>, + context: &str, + has_tool_use_template: bool, +) -> ProviderError { + let architecture = architecture + .map(str::trim) + .filter(|arch| !arch.is_empty()) + .map(|arch| format!(" Detected GGUF general.architecture={arch}.")) + .unwrap_or_default(); + let tool_use_note = if has_tool_use_template { + " A named tool_use chat template is present, but that template is only used for native tool calls with tools present." + } else { + "" + }; + + ProviderError::ExecutionError(format!( + "Model {model_id} does not contain GGUF tokenizer.chat_template metadata required for {context}.{architecture}{tool_use_note} \ + Goose cannot safely infer the correct prompt format from architecture alone. Select a \ + llama.cpp built-in chat template name, configure a custom inline chat template containing \ + the full Jinja template source, or use a GGUF that includes tokenizer.chat_template metadata." + )) +} + +fn load_chat_templates( + model: &LlamaModel, + settings: &ModelSettings, +) -> Result { + match &settings.chat_template { + ChatTemplate::Embedded => Ok(LoadedChatTemplates { + default: model.chat_template(None).ok(), + tool_use: model.chat_template(Some("tool_use")).ok(), + force_default: false, + }), + ChatTemplate::Builtin { name } => { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(ProviderError::ExecutionError( + "Built-in chat template name is empty. Enter a llama.cpp built-in template name such as 'chatml', or use embedded chat template metadata.".to_string(), + )); + } + LlamaChatTemplate::new(trimmed) + .map_err(|e| { + ProviderError::ExecutionError(format!( + "Built-in chat template name contains an invalid NUL byte: {e}" + )) + }) + .map(|template| LoadedChatTemplates { + default: Some(template), + tool_use: None, + force_default: true, + }) + } + ChatTemplate::CustomInline { template } => { + let trimmed = template.trim(); + if trimmed.is_empty() { + return Err(ProviderError::ExecutionError( + "Custom inline chat template is empty. Paste the full Jinja chat template source, use a llama.cpp built-in template name, or use embedded chat template metadata.".to_string(), + )); + } + if trimmed == "chatml" || is_legacy_builtin_template_name(trimmed) { + return Err(ProviderError::ExecutionError(format!( + "Custom inline chat template is set to '{trimmed}', which is a llama.cpp template name rather than Jinja template source. Paste the full Jinja chat template source instead, or select Built-in and enter '{trimmed}' if that built-in template is intended." + ))); + } + LlamaChatTemplate::new(template) + .map_err(|e| { + ProviderError::ExecutionError(format!( + "Custom inline chat template contains an invalid NUL byte: {e}" + )) + }) + .map(|template| LoadedChatTemplates { + default: Some(template), + tool_use: None, + force_default: true, + }) + } + } +} + +fn select_generation_template<'a>( + model_id: &str, + model: &LlamaModel, + templates: &'a LoadedChatTemplates, + native_tool_calling: bool, + has_tools: bool, +) -> Result<&'a LlamaChatTemplate, ProviderError> { + if templates.force_default { + return templates.default.as_ref().ok_or_else(|| { + ProviderError::ExecutionError( + "Configured chat template was not loaded correctly".to_string(), + ) + }); + } + + if native_tool_calling && has_tools { + if let Some(template) = templates.tool_use.as_ref() { + return Ok(template); + } + } + + templates.default.as_ref().ok_or_else(|| { + let architecture = model.meta_val_str("general.architecture").ok(); + let context = if has_tools && native_tool_calling { + "native tool calling because no tool_use template is available" + } else if has_tools { + "emulated tool calling" + } else { + "chat without tools" + }; + missing_chat_template_error( + model_id, + architecture.as_deref(), + context, + templates.tool_use.is_some(), + ) + }) +} + pub(super) struct LlamaCppBackend { backend: LlamaBackend, } @@ -134,18 +411,7 @@ impl LocalInferenceBackend for LlamaCppBackend { let model = LlamaModel::load_from_file(&self.backend, model_path, ¶ms) .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; - let template = match model.chat_template(None) { - Ok(t) => t, - Err(_) => { - tracing::warn!("Model has no embedded chat template, falling back to chatml"); - LlamaChatTemplate::new("chatml").map_err(|e| { - ProviderError::ExecutionError(format!( - "Failed to create fallback chat template: {}", - e - )) - })? - } - }; + let templates = load_chat_templates(&model, settings)?; let mtmd_ctx = Self::init_mtmd_context(&model, &resolved.mmproj_path, settings); @@ -157,7 +423,7 @@ impl LocalInferenceBackend for LlamaCppBackend { Ok(Box::new(LoadedModel { model, - template, + templates, mtmd_ctx, })) } @@ -174,14 +440,6 @@ impl LocalInferenceBackend for LlamaCppBackend { ProviderError::ExecutionError("Loaded model backend mismatch".to_string()) })?; - let native_tool_calling = request.settings.native_tool_calling; - let use_emulator = !native_tool_calling && !request.tools.is_empty(); - let system_prompt = if use_emulator { - load_tiny_model_prompt() - } else { - request.system.to_string() - }; - let has_vision = request.resolved_model.mmproj_path.is_some(); let marker = llama_cpp_2::mtmd::mtmd_default_marker(); let (images, vision_messages): (Vec, Option>) = if has_vision { @@ -193,45 +451,8 @@ impl LocalInferenceBackend for LlamaCppBackend { }; let effective_messages = vision_messages.as_deref().unwrap_or(request.messages); - let mut chat_messages = - vec![ - LlamaChatMessage::new("system".to_string(), system_prompt.clone()).map_err( - |e| { - ProviderError::ExecutionError(format!( - "Failed to create system message: {}", - e - )) - }, - )?, - ]; - let code_mode_enabled = request.tools.iter().any(|t| t.name == CODE_EXECUTION_TOOL); - - if use_emulator && !request.tools.is_empty() { - let tool_desc = build_emulator_tool_description(request.tools, code_mode_enabled); - chat_messages = vec![LlamaChatMessage::new( - "system".to_string(), - format!("{}{}", system_prompt, tool_desc), - ) - .map_err(|e| { - ProviderError::ExecutionError(format!("Failed to create system message: {}", e)) - })?]; - } - - for msg in effective_messages { - let role = match msg.role { - Role::User => "user", - Role::Assistant => "assistant", - }; - let content = extract_text_content(msg); - if !content.trim().is_empty() { - chat_messages.push(LlamaChatMessage::new(role.to_string(), content).map_err( - |e| ProviderError::ExecutionError(format!("Failed to create message: {}", e)), - )?); - } - } - - let (full_tools_json, compact_tools) = if !use_emulator && !request.tools.is_empty() { + let (full_tools_json, compact_tools) = if !request.tools.is_empty() { let full = format_tools(request.tools) .ok() .and_then(|spec| serde_json::to_string(&spec).ok()); @@ -241,13 +462,53 @@ impl LocalInferenceBackend for LlamaCppBackend { (None, None) }; - let oai_messages_json = if request.settings.use_jinja || native_tool_calling { - Some(build_openai_messages_json( - &system_prompt, - effective_messages, - )) + let has_native_tool_payload = full_tools_json + .as_deref() + .is_some_and(|tools| !tools.trim().is_empty()); + let template_supports_native = + if matches!(request.settings.tool_calling, ToolCallingMode::Auto) + && has_native_tool_payload + { + let messages_json = build_openai_messages_json(request.system, effective_messages); + if let Some(template) = loaded.templates.tool_use.as_ref() { + supports_native_tool_calling( + loaded, + request.settings, + template, + &messages_json, + full_tools_json.as_deref(), + ) + } else { + loaded.templates.default.as_ref().is_some_and(|template| { + supports_native_tool_calling( + loaded, + request.settings, + template, + &messages_json, + full_tools_json.as_deref(), + ) + }) + } + } else { + false + }; + let native_tool_calling = should_use_native_tool_calling( + request.settings.tool_calling, + !request.tools.is_empty(), + template_supports_native, + ); + let use_emulator = !native_tool_calling && !request.tools.is_empty(); + let system_prompt = if use_emulator { + let tool_desc = build_emulator_tool_description(request.tools, code_mode_enabled); + format!("{}{}", load_tiny_model_prompt(), tool_desc) } else { - None + request.system.to_string() + }; + + let oai_messages_json = if use_emulator { + build_openai_text_messages_json(&system_prompt, effective_messages) + } else { + build_openai_messages_json(&system_prompt, effective_messages) }; if !images.is_empty() && loaded.mtmd_ctx.is_none() { @@ -258,10 +519,18 @@ impl LocalInferenceBackend for LlamaCppBackend { ); } + let template = select_generation_template( + &request.model_name, + &loaded.model, + &loaded.templates, + native_tool_calling, + !request.tools.is_empty(), + )?; + let mut gen_ctx = GenerationContext { loaded, backend: self, - chat_messages: &chat_messages, + template, settings: request.settings, context_limit: request.context_limit, model_name: request.model_name, @@ -272,7 +541,7 @@ impl LocalInferenceBackend for LlamaCppBackend { }; if use_emulator { - generate_with_emulated_tools(&mut gen_ctx, code_mode_enabled) + generate_with_emulated_tools(&mut gen_ctx, code_mode_enabled, &oai_messages_json) } else { generate_with_native_tools( &mut gen_ctx, @@ -353,3 +622,78 @@ fn log_inference_backend_devices() { ); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn template_result(parser: Option<&str>, parse_tool_calls: bool) -> ChatTemplateResult { + ChatTemplateResult { + prompt: String::new(), + grammar: None, + grammar_lazy: false, + grammar_triggers: Vec::new(), + preserved_tokens: Vec::new(), + additional_stops: Vec::new(), + chat_format: 0, + parser: parser.map(str::to_string), + generation_prompt: String::new(), + parse_tool_calls, + } + } + + #[test] + fn native_tool_calling_requires_generated_parser() { + assert!(template_result_supports_native_tool_calling( + &template_result(Some("parser"), true) + )); + assert!(!template_result_supports_native_tool_calling( + &template_result(None, true) + )); + assert!(!template_result_supports_native_tool_calling( + &template_result(Some("parser"), false) + )); + assert!(!template_result_supports_native_tool_calling( + &template_result(Some(" "), true) + )); + } + + #[test] + fn tool_calling_mode_controls_path_selection() { + assert!(should_use_native_tool_calling( + ToolCallingMode::Auto, + true, + true + )); + assert!(!should_use_native_tool_calling( + ToolCallingMode::Auto, + true, + false + )); + assert!(should_use_native_tool_calling( + ToolCallingMode::ForceNative, + true, + false + )); + assert!(!should_use_native_tool_calling( + ToolCallingMode::ForceEmulated, + true, + true + )); + assert!(!should_use_native_tool_calling( + ToolCallingMode::ForceNative, + false, + true + )); + } + + #[test] + fn rejects_legacy_builtin_names_as_inline_templates() { + assert!(is_legacy_builtin_template_name("gemma")); + assert!(is_legacy_builtin_template_name("llama3")); + assert!(!is_legacy_builtin_template_name("chatml")); + assert!(!is_legacy_builtin_template_name( + "{% for message in messages %}{{ message.content }}{% endfor %}" + )); + } +} diff --git a/crates/goose/src/providers/local_inference/local_model_registry.rs b/crates/goose/src/providers/local_inference/local_model_registry.rs index b6a4a6f306..abe9ee6355 100644 --- a/crates/goose/src/providers/local_inference/local_model_registry.rs +++ b/crates/goose/src/providers/local_inference/local_model_registry.rs @@ -36,6 +36,29 @@ impl Default for SamplingConfig { } } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ToolCallingMode { + #[default] + Auto, + ForceNative, + ForceEmulated, +} + +#[derive(Debug, Clone, Default, Hash, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ChatTemplate { + #[serde(alias = "auto")] + #[default] + Embedded, + Builtin { + name: String, + }, + CustomInline { + template: String, + }, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ModelSettings { pub context_size: Option, @@ -57,13 +80,13 @@ pub struct ModelSettings { pub flash_attention: Option, pub n_threads: Option, #[serde(default)] - pub native_tool_calling: bool, + pub tool_calling: ToolCallingMode, #[serde(default)] - pub use_jinja: bool, + pub chat_template: ChatTemplate, #[serde(default = "default_true")] pub enable_thinking: bool, /// Whether this model architecture supports vision input. - /// Derived from the featured model table, not user-configurable. + /// Derived from associated mmproj metadata, not user-configurable. #[serde(default)] pub vision_capable: bool, /// Estimated tokens per image for budget planning before mtmd tokenization. @@ -106,8 +129,8 @@ impl Default for ModelSettings { use_mlock: false, flash_attention: None, n_threads: None, - native_tool_calling: false, - use_jinja: false, + tool_calling: ToolCallingMode::Auto, + chat_template: ChatTemplate::Embedded, enable_thinking: true, vision_capable: false, image_token_estimate: default_image_token_estimate(), @@ -116,100 +139,43 @@ impl Default for ModelSettings { } } -/// HuggingFace repo + filename for multimodal projection weights (vision encoder). -pub struct MmprojSpec { - pub repo: &'static str, - pub filename: &'static str, -} - -impl MmprojSpec { - /// Local path for this mmproj, namespaced by repo to avoid collisions - /// between different models that use the same filename. - pub fn local_path(&self) -> std::path::PathBuf { - let repo_name = self.repo.split('/').next_back().unwrap_or(self.repo); - Paths::in_data_dir("models") - .join(repo_name) - .join(self.filename) - } -} - pub struct FeaturedModel { /// HuggingFace spec in "author/repo-GGUF:quantization" format. pub spec: &'static str, - /// Whether this model's GGUF template supports native tool calling via llama.cpp. - pub native_tool_calling: bool, - /// Multimodal projection weights spec. None for text-only models. - pub mmproj: Option, } pub const FEATURED_MODELS: &[FeaturedModel] = &[ FeaturedModel { spec: "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M", - native_tool_calling: false, - mmproj: None, }, FeaturedModel { spec: "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M", - native_tool_calling: false, - mmproj: None, }, FeaturedModel { spec: "bartowski/Hermes-2-Pro-Mistral-7B-GGUF:Q4_K_M", - native_tool_calling: false, - mmproj: None, }, FeaturedModel { spec: "bartowski/Mistral-Small-24B-Instruct-2501-GGUF:Q4_K_M", - native_tool_calling: false, - mmproj: None, }, FeaturedModel { spec: "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M", - native_tool_calling: true, - mmproj: Some(MmprojSpec { - repo: "unsloth/gemma-4-E4B-it-GGUF", - filename: "mmproj-BF16.gguf", - }), }, FeaturedModel { spec: "unsloth/gemma-4-26B-A4B-it-GGUF:Q4_K_M", - native_tool_calling: true, - mmproj: Some(MmprojSpec { - repo: "unsloth/gemma-4-26B-A4B-it-GGUF", - filename: "mmproj-BF16.gguf", - }), }, ]; -pub fn default_settings_for_model(model_id: &str) -> ModelSettings { - use super::hf_models::parse_model_spec; - let model_repo = model_id.split(':').next().unwrap_or(model_id); - let featured = FEATURED_MODELS.iter().find(|m| { - if let Ok((repo_id, _quant)) = parse_model_spec(m.spec) { - repo_id == model_repo - } else { - false - } - }); +pub fn default_settings_for_model(_model_id: &str) -> ModelSettings { ModelSettings { - native_tool_calling: featured.is_some_and(|m| m.native_tool_calling), - vision_capable: featured.is_some_and(|m| m.mmproj.is_some()), ..ModelSettings::default() } } -/// Look up the `MmprojSpec` for a featured model by its model ID. -pub fn featured_mmproj_spec(model_id: &str) -> Option<&'static MmprojSpec> { - use super::hf_models::parse_model_spec; - let model_repo = model_id.split(':').next().unwrap_or(model_id); - FEATURED_MODELS.iter().find_map(|m| { - if let Ok((repo_id, _quant)) = parse_model_spec(m.spec) { - if repo_id == model_repo { - return m.mmproj.as_ref(); - } - } - None - }) +/// Local path for an mmproj file, namespaced by repo to avoid collisions +/// between different models that use the same filename. +pub fn mmproj_local_path(repo_id: &str, filename: &str) -> PathBuf { + let repo_name = repo_id.split('/').next_back().unwrap_or(repo_id); + Paths::in_data_dir("models").join(repo_name).join(filename) } /// Check if a model ID corresponds to a featured model. @@ -263,32 +229,27 @@ pub struct LocalModelEntry { #[serde(default)] pub mmproj_size_bytes: u64, #[serde(default)] + pub mmproj_checked: bool, + #[serde(default)] pub shard_files: Vec, } impl LocalModelEntry { - /// Populate mmproj metadata and vision settings from the featured model - /// table if this model's repo has a known vision encoder. - pub fn enrich_with_featured_mmproj(&mut self) { - if let Some(mmproj) = featured_mmproj_spec(&self.id) { - let path = mmproj.local_path(); - if self.mmproj_path.as_ref() != Some(&path) { - self.mmproj_path = Some(path.clone()); - self.mmproj_source_url = Some(format!( - "https://huggingface.co/{}/resolve/main/{}", - mmproj.repo, mmproj.filename - )); - } + pub fn refresh_mmproj_metadata(&mut self) { + self.settings.vision_capable = self.mmproj_path.is_some(); + if let Some(path) = &self.mmproj_path { + self.mmproj_checked = true; self.settings.vision_capable = true; if self.mmproj_size_bytes == 0 || self.settings.mmproj_size_bytes == 0 { - if let Ok(meta) = std::fs::metadata(&path) { + if let Ok(meta) = std::fs::metadata(path) { self.mmproj_size_bytes = meta.len(); self.settings.mmproj_size_bytes = meta.len(); } } + } else { + self.mmproj_size_bytes = 0; + self.settings.mmproj_size_bytes = 0; } - let defaults = default_settings_for_model(&self.id); - self.settings.native_tool_calling = defaults.native_tool_calling; } pub fn is_downloaded(&self) -> bool { @@ -436,7 +397,7 @@ impl LocalModelRegistry { for mut entry in featured_entries { if !self.models.iter().any(|m| m.id == entry.id) { - entry.enrich_with_featured_mmproj(); + entry.refresh_mmproj_metadata(); self.models.push(entry); changed = true; } @@ -455,7 +416,7 @@ impl LocalModelRegistry { } pub fn add_model(&mut self, mut entry: LocalModelEntry) -> Result<()> { - entry.enrich_with_featured_mmproj(); + entry.refresh_mmproj_metadata(); if let Some(existing) = self.models.iter_mut().find(|m| m.id == entry.id) { *existing = entry; } else { diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 7e2f3bcd32..a67d6b62fd 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1993,6 +1993,29 @@ } } }, + "/local-inference/chat-templates/builtin": { + "get": { + "tags": [ + "super::routes::local_inference" + ], + "operationId": "list_builtin_chat_templates", + "responses": { + "200": { + "description": "llama.cpp built-in chat template names", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, "/local-inference/download": { "post": { "tags": [ @@ -4260,6 +4283,63 @@ } } }, + "ChatTemplate": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "embedded" + ] + } + } + }, + { + "type": "object", + "required": [ + "name", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "builtin" + ] + } + } + }, + { + "type": "object", + "required": [ + "template", + "type" + ], + "properties": { + "template": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "custom_inline" + ] + } + } + } + ], + "discriminator": { + "propertyName": "type" + } + }, "CheckProviderRequest": { "type": "object", "required": [ @@ -6716,6 +6796,9 @@ "ModelSettings": { "type": "object", "properties": { + "chat_template": { + "$ref": "#/components/schemas/ChatTemplate" + }, "context_size": { "type": "integer", "format": "int32", @@ -6766,9 +6849,6 @@ "format": "int32", "nullable": true }, - "native_tool_calling": { - "type": "boolean" - }, "presence_penalty": { "type": "number", "format": "float" @@ -6784,15 +6864,15 @@ "sampling": { "$ref": "#/components/schemas/SamplingConfig" }, - "use_jinja": { - "type": "boolean" + "tool_calling": { + "$ref": "#/components/schemas/ToolCallingMode" }, "use_mlock": { "type": "boolean" }, "vision_capable": { "type": "boolean", - "description": "Whether this model architecture supports vision input.\nDerived from the featured model table, not user-configurable." + "description": "Whether this model architecture supports vision input.\nDerived from associated mmproj metadata, not user-configurable." } } }, @@ -8797,6 +8877,14 @@ } } }, + "ToolCallingMode": { + "type": "string", + "enum": [ + "auto", + "force_native", + "force_emulated" + ] + }, "ToolConfirmationRequest": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/index.ts b/ui/desktop/src/api/index.ts index d42bc29fc7..871f52ded6 100644 --- a/ui/desktop/src/api/index.ts +++ b/ui/desktop/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { addExtension, agentAddExtension, agentRemoveExtension, callTool, cancelDownload, cancelLocalModelDownload, checkProvider, cleanupProviderCache, configureProviderOauth, confirmToolAction, createCustomProvider, createRecipe, createSchedule, decodeRecipe, deleteLocalModel, deleteModel, deleteRecipe, deleteSchedule, deleteSession, diagnostics, downloadHfModel, downloadModel, encodeRecipe, exportApp, exportSession, forkSession, getCanonicalModelInfo, getCustomProvider, getDictationConfig, getDownloadProgress, getExtensions, getFeatures, getLocalModelDownloadProgress, getModelSettings, getPrompt, getPrompts, getProviderCatalog, getProviderCatalogTemplate, getProviderModelInfo, getProviderModels, getRepoFiles, getSession, getSessionExtensions, getSessionInsights, getSlashCommands, getTools, getTunnelStatus, importApp, importSession, importSessionNostr, inspectRunningJob, killRunningJob, listApps, listLocalModels, listModels, listRecipes, listSchedules, listSessions, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, searchHfModels, searchSessions, sendTelemetryEvent, sessionCancel, sessionEvents, sessionReply, sessionsHandler, setConfigProvider, setRecipeSlashCommand, shareSessionNostr, startAgent, startNanogptSetup, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, syncFeaturedModels, systemInfo, transcribeDictation, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateModelSettings, updateSchedule, updateSession, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; -export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, AuthorRequest, CallToolData, CallToolError, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, CancelRequest, ChatRequest, CheckProviderData, CheckProviderRequest, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponse, CleanupProviderCacheResponses, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, ContentBlock, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponse2, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, DictationProvider, DictationProviderStatus, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponse, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelRequest, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, EnvVarConfig, ErrorResponse, ExportAppData, ExportAppError, ExportAppErrors, ExportAppResponse, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, FeaturesResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCanonicalModelInfoData, GetCanonicalModelInfoResponse, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetFeaturesData, GetFeaturesResponse, GetFeaturesResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponse, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponse, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponse, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponse, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponse, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponse, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, GooseMode, HfGgufFile, HfModelInfo, HfQuantVariant, Icon, IconTheme, ImageContent, ImportAppData, ImportAppError, ImportAppErrors, ImportAppRequest, ImportAppResponse, ImportAppResponse2, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionNostrData, ImportSessionNostrErrors, ImportSessionNostrRequest, ImportSessionNostrResponse, ImportSessionNostrResponses, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, InferenceMetadata, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListLocalModelsData, ListLocalModelsResponse, ListLocalModelsResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LoadedProvider, LocalModelResponse, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelCapabilities, ModelConfig, ModelDownloadStatus, ModelInfo, ModelInfoData, ModelInfoQuery, ModelInfoResponse, ModelSettings, ModelTemplate, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderCatalogEntry, ProviderDetails, ProviderEngine, ProviderMetadata, ProviderModelInfoQuery, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderTemplate, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, RepoVariantsResponse, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SamplingConfig, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponse, SearchHfModelsResponses, SearchSessionsData, SearchSessionsErrors, SearchSessionsResponse, SearchSessionsResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionCancelData, SessionCancelResponses, SessionDisplayInfo, SessionEventsData, SessionEventsErrors, SessionEventsResponse, SessionEventsResponses, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionReplyData, SessionReplyErrors, SessionReplyRequest, SessionReplyResponse, SessionReplyResponse2, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, ShareSessionNostrData, ShareSessionNostrErrors, ShareSessionNostrRequest, ShareSessionNostrResponse, ShareSessionNostrResponse2, ShareSessionNostrResponses, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartNanogptSetupData, StartNanogptSetupResponse, StartNanogptSetupResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SyncFeaturedModelsData, SyncFeaturedModelsResponses, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, ThinkingContent, ThinkingEffort, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponse, UpdateModelSettingsResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionRequest, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; +export { addExtension, agentAddExtension, agentRemoveExtension, callTool, cancelDownload, cancelLocalModelDownload, checkProvider, cleanupProviderCache, configureProviderOauth, confirmToolAction, createCustomProvider, createRecipe, createSchedule, decodeRecipe, deleteLocalModel, deleteModel, deleteRecipe, deleteSchedule, deleteSession, diagnostics, downloadHfModel, downloadModel, encodeRecipe, exportApp, exportSession, forkSession, getCanonicalModelInfo, getCustomProvider, getDictationConfig, getDownloadProgress, getExtensions, getFeatures, getLocalModelDownloadProgress, getModelSettings, getPrompt, getPrompts, getProviderCatalog, getProviderCatalogTemplate, getProviderModelInfo, getProviderModels, getRepoFiles, getSession, getSessionExtensions, getSessionInsights, getSlashCommands, getTools, getTunnelStatus, importApp, importSession, importSessionNostr, inspectRunningJob, killRunningJob, listApps, listBuiltinChatTemplates, listLocalModels, listModels, listRecipes, listSchedules, listSessions, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, searchHfModels, searchSessions, sendTelemetryEvent, sessionCancel, sessionEvents, sessionReply, sessionsHandler, setConfigProvider, setRecipeSlashCommand, shareSessionNostr, startAgent, startNanogptSetup, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, syncFeaturedModels, systemInfo, transcribeDictation, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateModelSettings, updateSchedule, updateSession, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; +export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, AuthorRequest, CallToolData, CallToolError, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, CancelRequest, ChatRequest, ChatTemplate, CheckProviderData, CheckProviderRequest, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponse, CleanupProviderCacheResponses, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, ContentBlock, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponse2, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, DictationProvider, DictationProviderStatus, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponse, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelRequest, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, EnvVarConfig, ErrorResponse, ExportAppData, ExportAppError, ExportAppErrors, ExportAppResponse, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, FeaturesResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCanonicalModelInfoData, GetCanonicalModelInfoResponse, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetFeaturesData, GetFeaturesResponse, GetFeaturesResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponse, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponse, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponse, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponse, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponse, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponse, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, GooseMode, HfGgufFile, HfModelInfo, HfQuantVariant, Icon, IconTheme, ImageContent, ImportAppData, ImportAppError, ImportAppErrors, ImportAppRequest, ImportAppResponse, ImportAppResponse2, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionNostrData, ImportSessionNostrErrors, ImportSessionNostrRequest, ImportSessionNostrResponse, ImportSessionNostrResponses, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, InferenceMetadata, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListBuiltinChatTemplatesData, ListBuiltinChatTemplatesResponse, ListBuiltinChatTemplatesResponses, ListLocalModelsData, ListLocalModelsResponse, ListLocalModelsResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LoadedProvider, LocalModelResponse, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelCapabilities, ModelConfig, ModelDownloadStatus, ModelInfo, ModelInfoData, ModelInfoQuery, ModelInfoResponse, ModelSettings, ModelTemplate, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderCatalogEntry, ProviderDetails, ProviderEngine, ProviderMetadata, ProviderModelInfoQuery, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderTemplate, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, RepoVariantsResponse, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SamplingConfig, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponse, SearchHfModelsResponses, SearchSessionsData, SearchSessionsErrors, SearchSessionsResponse, SearchSessionsResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionCancelData, SessionCancelResponses, SessionDisplayInfo, SessionEventsData, SessionEventsErrors, SessionEventsResponse, SessionEventsResponses, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionReplyData, SessionReplyErrors, SessionReplyRequest, SessionReplyResponse, SessionReplyResponse2, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, ShareSessionNostrData, ShareSessionNostrErrors, ShareSessionNostrRequest, ShareSessionNostrResponse, ShareSessionNostrResponse2, ShareSessionNostrResponses, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartNanogptSetupData, StartNanogptSetupResponse, StartNanogptSetupResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SyncFeaturedModelsData, SyncFeaturedModelsResponses, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, ThinkingContent, ThinkingEffort, TokenState, Tool, ToolAnnotations, ToolCallingMode, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponse, UpdateModelSettingsResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionRequest, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index c98c916408..081dfb57fc 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, CallToolData, CallToolErrors, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, CheckProviderData, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponses, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportAppData, ExportAppErrors, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetCanonicalModelInfoData, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetFeaturesData, GetFeaturesResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportAppData, ImportAppErrors, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionNostrData, ImportSessionNostrErrors, ImportSessionNostrResponses, ImportSessionResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListLocalModelsData, ListLocalModelsResponses, ListModelsData, ListModelsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponses, SearchSessionsData, SearchSessionsErrors, SearchSessionsResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionCancelData, SessionCancelResponses, SessionEventsData, SessionEventsErrors, SessionEventsResponses, SessionReplyData, SessionReplyErrors, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, ShareSessionNostrData, ShareSessionNostrErrors, ShareSessionNostrResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartNanogptSetupData, StartNanogptSetupResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, SyncFeaturedModelsData, SyncFeaturedModelsResponses, SystemInfoData, SystemInfoResponses, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, CallToolData, CallToolErrors, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, CheckProviderData, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponses, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportAppData, ExportAppErrors, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetCanonicalModelInfoData, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetFeaturesData, GetFeaturesResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportAppData, ImportAppErrors, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionNostrData, ImportSessionNostrErrors, ImportSessionNostrResponses, ImportSessionResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListBuiltinChatTemplatesData, ListBuiltinChatTemplatesResponses, ListLocalModelsData, ListLocalModelsResponses, ListModelsData, ListModelsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponses, SearchSessionsData, SearchSessionsErrors, SearchSessionsResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionCancelData, SessionCancelResponses, SessionEventsData, SessionEventsErrors, SessionEventsResponses, SessionReplyData, SessionReplyErrors, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, ShareSessionNostrData, ShareSessionNostrErrors, ShareSessionNostrResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartNanogptSetupData, StartNanogptSetupResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, SyncFeaturedModelsData, SyncFeaturedModelsResponses, SystemInfoData, SystemInfoResponses, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -321,6 +321,8 @@ export const startOpenrouterSetup = (optio export const startTetrateSetup = (options?: Options) => (options?.client ?? client).post({ url: '/handle_tetrate', ...options }); +export const listBuiltinChatTemplates = (options?: Options) => (options?.client ?? client).get({ url: '/local-inference/chat-templates/builtin', ...options }); + export const downloadHfModel = (options: Options) => (options.client ?? client).post({ url: '/local-inference/download', ...options, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index b5535a239f..a4d110403e 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -76,6 +76,16 @@ export type ChatRequest = { user_message: Message; }; +export type ChatTemplate = { + type: 'embedded'; +} | { + name: string; + type: 'builtin'; +} | { + template: string; + type: 'custom_inline'; +}; + export type CheckProviderRequest = { provider: string; }; @@ -863,6 +873,7 @@ export type ModelInfoResponse = { }; export type ModelSettings = { + chat_template?: ChatTemplate; context_size?: number | null; enable_thinking?: boolean; flash_attention?: boolean | null; @@ -880,16 +891,15 @@ export type ModelSettings = { n_batch?: number | null; n_gpu_layers?: number | null; n_threads?: number | null; - native_tool_calling?: boolean; presence_penalty?: number; repeat_last_n?: number; repeat_penalty?: number; sampling?: SamplingConfig; - use_jinja?: boolean; + tool_calling?: ToolCallingMode; use_mlock?: boolean; /** * Whether this model architecture supports vision input. - * Derived from the featured model table, not user-configurable. + * Derived from associated mmproj metadata, not user-configurable. */ vision_capable?: boolean; }; @@ -1544,6 +1554,8 @@ export type ToolAnnotations = { title?: string; }; +export type ToolCallingMode = 'auto' | 'force_native' | 'force_emulated'; + export type ToolConfirmationRequest = { arguments: JsonObject; id: string; @@ -3241,6 +3253,22 @@ export type StartTetrateSetupResponses = { export type StartTetrateSetupResponse = StartTetrateSetupResponses[keyof StartTetrateSetupResponses]; +export type ListBuiltinChatTemplatesData = { + body?: never; + path?: never; + query?: never; + url: '/local-inference/chat-templates/builtin'; +}; + +export type ListBuiltinChatTemplatesResponses = { + /** + * llama.cpp built-in chat template names + */ + 200: Array; +}; + +export type ListBuiltinChatTemplatesResponse = ListBuiltinChatTemplatesResponses[keyof ListBuiltinChatTemplatesResponses]; + export type DownloadHfModelData = { body: DownloadModelRequest; path?: never; diff --git a/ui/desktop/src/components/settings/localInference/ModelSettingsPanel.tsx b/ui/desktop/src/components/settings/localInference/ModelSettingsPanel.tsx index 8887ad65d5..e1b0661a79 100644 --- a/ui/desktop/src/components/settings/localInference/ModelSettingsPanel.tsx +++ b/ui/desktop/src/components/settings/localInference/ModelSettingsPanel.tsx @@ -4,9 +4,12 @@ import { Button } from '../../ui/button'; import { Switch } from '../../ui/switch'; import { getModelSettings, + listBuiltinChatTemplates, updateModelSettings, + type ChatTemplate, type ModelSettings, type SamplingConfig, + type ToolCallingMode, } from '../../../api'; import { defineMessages, useIntl } from '../../../i18n'; @@ -161,16 +164,59 @@ const i18n = defineMessages({ }, toolCalling: { id: 'modelSettingsPanel.toolCalling', - defaultMessage: 'Tool Calling', + defaultMessage: 'Tool calling', }, - nativeToolCalling: { - id: 'modelSettingsPanel.nativeToolCalling', - defaultMessage: 'Native tool calling', + toolCallingDescription: { + id: 'modelSettingsPanel.toolCallingDescription', + defaultMessage: 'Choose how local models select native or emulated tool calling', }, - nativeToolCallingDescription: { - id: 'modelSettingsPanel.nativeToolCallingDescription', - defaultMessage: - "Use the model's built-in tool-call format instead of the shell-command emulator. Enable for large models that reliably support tool calling.", + toolCallingAuto: { + id: 'modelSettingsPanel.toolCallingAuto', + defaultMessage: 'Auto', + }, + toolCallingForceNative: { + id: 'modelSettingsPanel.toolCallingForceNative', + defaultMessage: 'Force native', + }, + toolCallingForceEmulated: { + id: 'modelSettingsPanel.toolCallingForceEmulated', + defaultMessage: 'Force emulated', + }, + chatTemplate: { + id: 'modelSettingsPanel.chatTemplate', + defaultMessage: 'Chat template', + }, + chatTemplateDescription: { + id: 'modelSettingsPanel.chatTemplateDescription', + defaultMessage: 'Use embedded GGUF metadata, a llama.cpp built-in template, or inline Jinja', + }, + chatTemplateEmbedded: { + id: 'modelSettingsPanel.chatTemplateEmbedded', + defaultMessage: 'Embedded', + }, + chatTemplateBuiltin: { + id: 'modelSettingsPanel.chatTemplateBuiltin', + defaultMessage: 'Built-in', + }, + chatTemplateCustomInline: { + id: 'modelSettingsPanel.chatTemplateCustomInline', + defaultMessage: 'Custom inline', + }, + builtinChatTemplate: { + id: 'modelSettingsPanel.builtinChatTemplate', + defaultMessage: 'Built-in template', + }, + builtinChatTemplateDescription: { + id: 'modelSettingsPanel.builtinChatTemplateDescription', + defaultMessage: 'Select a llama.cpp built-in template name', + }, + customChatTemplate: { + id: 'modelSettingsPanel.customChatTemplate', + defaultMessage: 'Custom chat template', + }, + customChatTemplateDescription: { + id: 'modelSettingsPanel.customChatTemplateDescription', + defaultMessage: 'Paste the full Jinja chat template source', }, }); @@ -194,10 +240,12 @@ const DEFAULT_SETTINGS: ModelSettings = { use_mlock: false, flash_attention: null, n_threads: null, - native_tool_calling: false, + tool_calling: 'auto', + chat_template: { type: 'embedded' }, }; type SamplingType = SamplingConfig['type']; +type ChatTemplateMode = 'embedded' | 'builtin' | 'custom_inline'; function NumberField({ label, @@ -302,16 +350,59 @@ function SelectField({ ); } +function TextAreaField({ + label, + description, + value, + onChange, + onBlur, +}: { + label: string; + description?: string; + value: string; + onChange: (v: string) => void; + onBlur: () => void; +}) { + return ( +
+ + {description && {description}} +