Switch the local inference provider MLX backend to use the safemlx crate (#10304)

This commit is contained in:
Jasper 2026-07-07 14:01:15 -07:00 committed by GitHub
parent f96f62d985
commit 1bced6616b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1835 additions and 646 deletions

318
Cargo.lock generated
View file

@ -1201,7 +1201,7 @@ dependencies = [
"bincode",
"bytesize",
"clircle",
"console 0.16.3",
"console",
"content_inspector",
"encoding_rs",
"flate2",
@ -1255,26 +1255,6 @@ dependencies = [
"serde",
]
[[package]]
name = "bindgen"
version = "0.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f"
dependencies = [
"bitflags 2.11.1",
"cexpr",
"clang-sys",
"itertools 0.13.0",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash 1.1.0",
"shlex 1.3.0",
"syn 2.0.117",
]
[[package]]
name = "bindgen"
version = "0.72.1"
@ -2302,8 +2282,8 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4529f45438fc25ca048b242d5c48e2d3ce9a521e2a5a9123d9737d8520b030dd"
dependencies = [
"console 0.16.3",
"indicatif 0.18.4",
"console",
"indicatif",
"once_cell",
"strsim",
"textwrap",
@ -2478,19 +2458,6 @@ dependencies = [
"winnow 1.0.3",
]
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width 0.2.2",
"windows-sys 0.59.0",
]
[[package]]
name = "console"
version = "0.16.3"
@ -5024,7 +4991,7 @@ dependencies = [
"clap_mangen",
"cliclack",
"comfy-table",
"console 0.16.3",
"console",
"dotenvy",
"env-lock",
"etcetera 0.11.0",
@ -5032,7 +4999,7 @@ dependencies = [
"goose",
"goose-mcp",
"goose-providers",
"indicatif 0.18.4",
"indicatif",
"open",
"rand 0.10.1",
"regex",
@ -5089,17 +5056,17 @@ dependencies = [
"goose-download-manager",
"goose-provider-types",
"goose-sdk-types",
"hf-hub 1.0.0-rc.1",
"hf-hub",
"include_dir",
"llama-cpp-2",
"llama-cpp-sys-2",
"minijinja",
"mlx-lm",
"mlx-lm-utils",
"mlx-rs",
"regex",
"reqwest 0.13.4",
"rmcp",
"safemlx",
"safemlx-lm",
"safemlx-lm-utils",
"serde",
"serde_json",
"tempfile",
@ -5447,25 +5414,6 @@ dependencies = [
"arrayvec",
]
[[package]]
name = "hf-hub"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97"
dependencies = [
"dirs",
"http 1.4.2",
"indicatif 0.17.11",
"libc",
"log",
"rand 0.9.4",
"serde",
"serde_json",
"thiserror 2.0.18",
"ureq",
"windows-sys 0.60.2",
]
[[package]]
name = "hf-hub"
version = "1.0.0-rc.1"
@ -6070,26 +6018,13 @@ dependencies = [
"serde_core",
]
[[package]]
name = "indicatif"
version = "0.17.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
dependencies = [
"console 0.15.11",
"number_prefix",
"portable-atomic",
"unicode-width 0.2.2",
"web-time",
]
[[package]]
name = "indicatif"
version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb"
dependencies = [
"console 0.16.3",
"console",
"portable-atomic",
"unicode-width 0.2.2",
"unit-prefix",
@ -6611,7 +6546,7 @@ version = "0.1.146"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b291e4bc2d10c43cd8dec16d49b6104cb3cb125f596ec380a753a5db1d965dd"
dependencies = [
"bindgen 0.72.1",
"bindgen",
"cc",
"cmake",
"find_cuda_helper",
@ -6862,99 +6797,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "mlx-internal-macros"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a7c4444d624bf6b93db5cc22ebff4fdfa13593fd56154fe33b1f302a557c2c6"
dependencies = [
"darling 0.21.3",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "mlx-lm"
version = "0.0.1"
source = "git+https://github.com/jh-block/mlx-lm#4cb8572045f55f170eb6e41005a3f8a730673c42"
dependencies = [
"anyhow",
"clap",
"idna_adapter",
"minijinja",
"mlx-lm-utils",
"mlx-macros",
"mlx-rs",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokenizers 0.22.2",
]
[[package]]
name = "mlx-lm-utils"
version = "0.0.1"
source = "git+https://github.com/jh-block/mlx-lm#4cb8572045f55f170eb6e41005a3f8a730673c42"
dependencies = [
"minijinja",
"minijinja-contrib",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokenizers 0.22.2",
]
[[package]]
name = "mlx-macros"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a819ee8b4434690572b6feb9c3ef0b6e90137e4190b340cf00150703b410aaf9"
dependencies = [
"darling 0.21.3",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "mlx-rs"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a0f592c5839b0237b3072530b1d3c503923579a2009ee0761df3edd1b1f27b"
dependencies = [
"bytemuck",
"dyn-clone",
"half",
"itertools 0.14.0",
"libc",
"mach-sys",
"mlx-internal-macros",
"mlx-macros",
"mlx-sys",
"num-complex",
"num-traits",
"num_enum",
"parking_lot",
"paste",
"safetensors 0.6.2",
"smallvec",
"strum 0.27.2",
"thiserror 2.0.18",
]
[[package]]
name = "mlx-sys"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e3bc3880111918b2d5018f845d48fd995f9901f16efc81d1fcfd2f4210b8219"
dependencies = [
"bindgen 0.70.1",
"cc",
"cmake",
]
[[package]]
name = "mockall"
version = "0.15.0"
@ -7398,19 +7240,13 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "number_prefix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "oauth2"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [
"base64 0.21.7",
"base64 0.22.1",
"chrono",
"getrandom 0.2.17",
"http 1.4.2",
@ -8564,7 +8400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
dependencies = [
"anyhow",
"itertools 0.13.0",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@ -9347,7 +9183,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
@ -9462,6 +9297,99 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3944826ff8fa8093089aba3acb4ef44b9446a99a16f3bf4e74af3f77d340ab7d"
[[package]]
name = "safemlx"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f501c4f9748dfe9ccc905e7e13ae88873d96a00735268c69258e158da07a777f"
dependencies = [
"bytemuck",
"dyn-clone",
"half",
"itertools 0.14.0",
"libc",
"mach-sys",
"num-complex",
"num-traits",
"num_enum",
"parking_lot",
"paste",
"safemlx-internal-macros",
"safemlx-macros",
"safemlx-sys",
"safetensors 0.6.2",
"smallvec",
"strum 0.27.2",
"thiserror 2.0.18",
]
[[package]]
name = "safemlx-internal-macros"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9708b1312176963a4ea6412859e6655e56a83fe6ab936142715aa0e9fd662fe5"
dependencies = [
"darling 0.21.3",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "safemlx-lm"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78f450654386009003b745adfdae7af2c7f44ae749d75621a2a0b07607945e9f"
dependencies = [
"anyhow",
"clap",
"idna_adapter",
"minijinja",
"safemlx",
"safemlx-lm-utils",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokenizers 0.22.2",
]
[[package]]
name = "safemlx-lm-utils"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "583d39124dae0b4a93ac7528d16d543f5e717c21365e6b034a8a74d8e1b4eb3a"
dependencies = [
"minijinja",
"minijinja-contrib",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokenizers 0.22.2",
]
[[package]]
name = "safemlx-macros"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d549c5fdbe69b904ef84c5cf352add203de1093b3c636e8133d94c24cfc33ce0"
dependencies = [
"darling 0.21.3",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "safemlx-sys"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "552285b007dab687890f5c49382e52e69a3bb1359bb2c8f2c2743743509af071"
dependencies = [
"cc",
"cmake",
]
[[package]]
name = "safetensors"
version = "0.4.5"
@ -10317,17 +10245,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "socks"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
dependencies = [
"byteorder",
"libc",
"winapi",
]
[[package]]
name = "sourcemap"
version = "9.3.2"
@ -11777,8 +11694,6 @@ dependencies = [
"derive_builder",
"esaxx-rs",
"getrandom 0.3.4",
"hf-hub 0.4.3",
"indicatif 0.18.4",
"itertools 0.14.0",
"log",
"macro_rules_attribute",
@ -12810,25 +12725,6 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"socks",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.8"
@ -12948,7 +12844,7 @@ version = "145.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f84468f393984251db025e944544590a8fb429d5088b78102bd72f09232f0da"
dependencies = [
"bindgen 0.72.1",
"bindgen",
"bitflags 2.11.1",
"fslock",
"gzip-header",

View file

@ -53,7 +53,7 @@ use std::collections::{HashMap, HashSet};
use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use std::time::{Duration, Instant};
use tokio;
use tokio_util::sync::CancellationToken;
use tracing::warn;
@ -1769,25 +1769,54 @@ fn print_run_stats(
usage: Option<&ProviderUsage>,
) {
let elapsed = run_started.elapsed();
let stats = usage.and_then(|usage| usage.stats.as_ref());
let generation_elapsed = stats
.and_then(|stats| stats.elapsed_ms)
.map(Duration::from_millis);
let output_tokens = usage
.and_then(|usage| usage.usage.output_tokens)
.and_then(|tokens| usize::try_from(tokens).ok())
.or_else(|| usage.and_then(|usage| usage.stats.as_ref()?.output_tokens));
.or_else(|| stats.and_then(|stats| stats.output_tokens));
let tokens_per_second = output_tokens.map(|tokens| {
if elapsed.as_secs_f64() > 0.0 {
tokens as f64 / elapsed.as_secs_f64()
let rate_elapsed = generation_elapsed.unwrap_or(elapsed);
if rate_elapsed.as_secs_f64() > 0.0 {
tokens as f64 / rate_elapsed.as_secs_f64()
} else {
0.0
}
});
let model_load_ms = stats.and_then(|stats| stats.model_load_ms);
let generation_time_to_first_token_ms = stats.and_then(|stats| stats.time_to_first_token_ms);
eprintln!("\nStats:");
match first_token_at {
Some(first) => eprintln!(
" Time to first token: {:.2}s",
first.duration_since(run_started).as_secs_f64()
),
None => eprintln!(" Time to first token: unavailable"),
if let Some(ms) = model_load_ms {
eprintln!(" Model load: {:.2}s", ms as f64 / 1000.0);
}
if model_load_ms.is_some() {
match generation_time_to_first_token_ms {
Some(ms) => eprintln!(
" Generation time to first token: {:.2}s",
ms as f64 / 1000.0
),
None => eprintln!(" Generation time to first token: unavailable"),
}
match first_token_at {
Some(first) => eprintln!(
" End-to-end time to first token: {:.2}s",
first.duration_since(run_started).as_secs_f64()
),
None => eprintln!(" End-to-end time to first token: unavailable"),
}
} else if let Some(ms) = generation_time_to_first_token_ms {
eprintln!(" Time to first token: {:.2}s", ms as f64 / 1000.0);
} else {
match first_token_at {
Some(first) => eprintln!(
" Time to first token: {:.2}s",
first.duration_since(run_started).as_secs_f64()
),
None => eprintln!(" Time to first token: unavailable"),
}
}
match tokens_per_second {
Some(rate) => eprintln!(" Tokens/sec: {:.2}", rate),
@ -1797,10 +1826,7 @@ fn print_run_stats(
eprintln!(" Output tokens: {tokens}");
}
if let Some(draft) = usage
.and_then(|usage| usage.stats.as_ref())
.and_then(|stats| stats.draft.as_ref())
{
if let Some(draft) = stats.and_then(|stats| stats.draft.as_ref()) {
eprintln!(" Draft accept rate: {:.1}%", draft.accept_rate * 100.0);
eprintln!(
" Draft tokens: {} accepted: {} target verified: {} rounds: {}",

View file

@ -248,7 +248,8 @@ pub fn render_message(message: &Message, debug: bool) {
}
MessageContent::SystemNotification(notification) => {
match notification.notification_type {
SystemNotificationType::ThinkingMessage => {
SystemNotificationType::ThinkingMessage
| SystemNotificationType::ProgressMessage => {
show_thinking();
set_thinking_message(&notification.msg);
}
@ -330,7 +331,8 @@ pub fn render_message_streaming(
}
MessageContent::SystemNotification(notification) => {
match notification.notification_type {
SystemNotificationType::ThinkingMessage => {
SystemNotificationType::ThinkingMessage
| SystemNotificationType::ProgressMessage => {
show_thinking();
set_thinking_message(&notification.msg);
}

View file

@ -15,7 +15,7 @@ workspace = true
default = []
cuda = ["llama-cpp-2/cuda"]
vulkan = ["llama-cpp-2/vulkan"]
mlx = ["dep:mlx-rs", "dep:mlx-lm", "dep:mlx-lm-utils"]
mlx = ["dep:safemlx", "dep:safemlx-lm", "dep:safemlx-lm-utils"]
[dependencies]
anyhow = { workspace = true }
@ -46,9 +46,9 @@ utoipa = { workspace = true, features = ["chrono"] }
uuid = { workspace = true, features = ["v4", "std"] }
tempfile = { workspace = true }
mlx-rs = { version = "0.25.3", default-features = false, features = ["accelerate", "metal", "safetensors"], optional = true }
mlx-lm = { git = "https://github.com/jh-block/mlx-lm", optional = true }
mlx-lm-utils = { git = "https://github.com/jh-block/mlx-lm", optional = true }
safemlx = { default-features = false, features = ["accelerate", "metal", "safetensors"], optional = true, version = "0.1.2" }
safemlx-lm = { optional = true, version = "0.1.5" }
safemlx-lm-utils = { optional = true, version = "0.1.2" }
[target.'cfg(target_os = "macos")'.dependencies]
llama-cpp-2 = { workspace = true, features = ["sampler", "metal", "mtmd"] }

View file

@ -22,6 +22,7 @@ pub(super) struct LocalGenerationRequest<'a> {
pub temperature: Option<f32>,
pub max_tokens: Option<i32>,
pub context_limit: usize,
pub model_load_ms: Option<u64>,
pub resolved_model: &'a ResolvedModelPaths,
pub draft_model_path: Option<std::path::PathBuf>,
pub message_id: &'a str,

View file

@ -1085,20 +1085,13 @@ mod tests {
}
#[test]
fn mlx_compatible_repo_accepts_supported_tokenizer_formats() {
fn mlx_compatible_repo_accepts_tokenizer_json() {
let config = Some(serde_json::json!({ "model_type": "llama" }));
for tokenizer_files in [
vec!["tokenizer.json"],
vec!["tokenizer.model"],
vec!["tokenizer.tiktoken"],
vec!["vocab.json", "merges.txt"],
] {
assert!(
is_mlx_compatible_repo(&config, &mlx_siblings(&tokenizer_files)),
"{tokenizer_files:?}"
);
}
assert!(is_mlx_compatible_repo(
&config,
&mlx_siblings(&["tokenizer.json"])
));
}
#[test]
@ -1107,8 +1100,11 @@ mod tests {
for tokenizer_files in [
vec!["tokenizer_config.json"],
vec!["tokenizer.model"],
vec!["tokenizer.tiktoken"],
vec!["vocab.json"],
vec!["merges.txt"],
vec!["vocab.json", "merges.txt"],
] {
assert!(
!is_mlx_compatible_repo(&config, &mlx_siblings(&tokenizer_files)),
@ -1117,6 +1113,75 @@ mod tests {
}
}
#[test]
fn mlx_download_filenames_include_fp8_snapshot_metadata() {
let siblings = [
"config.json",
"configuration.json",
"generation_config.json",
"model.safetensors.index.json",
"layers-0.safetensors",
"outside.safetensors",
"tokenizer.json",
"tokenizer_config.json",
"chat_template.jinja",
"preprocessor_config.json",
"video_preprocessor_config.json",
"README.md",
]
.into_iter()
.map(sibling)
.collect::<Vec<_>>();
let filenames = mlx_download_filenames(&siblings)
.into_iter()
.collect::<std::collections::HashSet<_>>();
for filename in [
"configuration.json",
"chat_template.jinja",
"preprocessor_config.json",
"video_preprocessor_config.json",
] {
assert!(filenames.contains(filename), "{filename}");
}
assert!(!filenames.contains("README.md"));
}
#[test]
fn mlx_download_size_uses_safetensors_metadata_when_sibling_sizes_are_missing() {
let info: ModelInfo = serde_json::from_value(serde_json::json!({
"id": "owner/repo",
"safetensors": {
"parameters": {
"BF16": 10,
"F8_E4M3": 20
},
"total": 30
}
}))
.unwrap();
let siblings = vec![
RepoSibling {
rfilename: "config.json".to_string(),
size: None,
lfs: None,
},
RepoSibling {
rfilename: "model.safetensors".to_string(),
size: None,
lfs: None,
},
];
assert_eq!(mlx_download_size_bytes(&info, &siblings), 40);
}
#[test]
fn mlx_variant_id_detects_fp8_repo_name() {
assert_eq!(mlx_variant_id("Qwen/Qwen3.6-35B-A3B-FP8", &None), "fp8");
}
fn test_model(repo_id: &str) -> HfModelInfo {
HfModelInfo {
repo_id: repo_id.to_string(),
@ -1157,6 +1222,13 @@ mod tests {
assert_eq!(results[0].repo_id, "gguf/repo");
}
#[test]
fn best_download_count_ignores_zero_primary() {
assert_eq!(best_download_count(Some(0), Some(42)), Some(42));
assert_eq!(best_download_count(Some(7), Some(42)), Some(7));
assert_eq!(best_download_count(Some(0), Some(0)), None);
}
#[test]
fn test_recommend_variant() {
let variants = vec![
@ -1468,7 +1540,13 @@ async fn search_mlx_models_with_query(query: &str, limit: usize) -> Result<Vec<H
if results.len() >= limit {
break;
}
if let Some(model) = get_local_model_info_for_repo_with_client(&client, &info.id).await? {
if let Some(model) = get_local_model_info_for_repo_with_client_and_downloads(
&client,
&info.id,
info.downloads,
)
.await?
{
results.push(model);
}
}
@ -1484,6 +1562,14 @@ async fn get_local_model_info_for_repo(repo_id: &str) -> Result<Option<HfModelIn
async fn get_local_model_info_for_repo_with_client(
client: &HFClient,
repo_id: &str,
) -> Result<Option<HfModelInfo>> {
get_local_model_info_for_repo_with_client_and_downloads(client, repo_id, None).await
}
async fn get_local_model_info_for_repo_with_client_and_downloads(
client: &HFClient,
repo_id: &str,
downloads_hint: Option<u64>,
) -> Result<Option<HfModelInfo>> {
let repo = model_repo(client, repo_id)?;
let info = repo
@ -1495,7 +1581,7 @@ async fn get_local_model_info_for_repo_with_client(
])
.send()
.await?;
model_info_to_local_model_info(info).await
model_info_to_local_model_info(&repo, info, downloads_hint).await
}
async fn get_exact_name_local_model_info(model_name: &str) -> Result<Option<HfModelInfo>> {
@ -1508,7 +1594,11 @@ async fn get_exact_name_local_model_info(model_name: &str) -> Result<Option<HfMo
Ok(None)
}
async fn model_info_to_local_model_info(info: ModelInfo) -> Result<Option<HfModelInfo>> {
async fn model_info_to_local_model_info(
repo: &HFRepository<RepoTypeModel>,
info: ModelInfo,
downloads_hint: Option<u64>,
) -> Result<Option<HfModelInfo>> {
let repo_id = info.id.clone();
let mut variants: Vec<HfModelVariant> = get_repo_gguf_variants(&repo_id)
.await
@ -1516,7 +1606,13 @@ async fn model_info_to_local_model_info(info: ModelInfo) -> Result<Option<HfMode
.iter()
.map(|variant| variant.to_model_variant(&repo_id))
.collect();
variants.extend(mlx_variants_from_model_info(&repo_id, &info));
if is_mlx_compatible_model_info(&info) {
let mlx_config = load_repo_config_json(repo).await.unwrap_or_else(|error| {
tracing::debug!(repo_id, %error, "Failed to load MLX config.json; falling back to API config");
info.config.clone()
});
variants.extend(mlx_variants_from_model_info(&repo_id, &info, &mlx_config));
}
if variants.is_empty() {
return Ok(None);
@ -1530,17 +1626,49 @@ async fn model_info_to_local_model_info(info: ModelInfo) -> Result<Option<HfMode
.next_back()
.unwrap_or(&repo_id)
.to_string();
let downloads = match best_download_count(info.downloads, downloads_hint) {
Some(downloads) => downloads,
None => get_repo_downloads(&repo_id).await?.unwrap_or(0),
};
Ok(Some(HfModelInfo {
repo_id,
author,
model_name,
downloads: info.downloads.unwrap_or(0),
downloads,
gguf_files: Vec::new(),
variants,
}))
}
fn best_download_count(primary: Option<u64>, hint: Option<u64>) -> Option<u64> {
primary
.filter(|downloads| *downloads > 0)
.or_else(|| hint.filter(|downloads| *downloads > 0))
}
async fn get_repo_downloads(repo_id: &str) -> Result<Option<u64>> {
let client = reqwest::Client::new();
let token = optional_hf_token(huggingface_auth::resolve_token_async()).await;
let url = format!("{}/{}", HF_API_BASE, repo_id);
let response = apply_hf_auth(client.get(&url), token.as_deref())
.header("User-Agent", "goose-ai-agent")
.send()
.await?;
if !response.status().is_success() {
bail!(
"HuggingFace API returned status {} for repo {}",
response.status(),
repo_id
);
}
let model: HfApiModel = response.json().await?;
Ok(model.downloads)
}
pub async fn get_repo_local_variants(repo_id: &str) -> Result<Vec<HfModelVariant>> {
let mut variants: Vec<HfModelVariant> = get_repo_gguf_variants(repo_id)
.await
@ -1570,25 +1698,39 @@ pub async fn get_repo_mlx_variants(repo_id: &str) -> Result<Vec<HfModelVariant>>
])
.send()
.await?;
Ok(mlx_variants_from_model_info(repo_id, &info))
if !is_mlx_compatible_model_info(&info) {
return Ok(Vec::new());
}
let mlx_config = load_repo_config_json(&repo)
.await
.unwrap_or_else(|_| info.config.clone());
Ok(mlx_variants_from_model_info(repo_id, &info, &mlx_config))
}
fn mlx_variants_from_model_info(repo_id: &str, info: &ModelInfo) -> Vec<HfModelVariant> {
async fn load_repo_config_json(
repo: &HFRepository<RepoTypeModel>,
) -> Result<Option<serde_json::Value>> {
let config_path = repo
.download_file()
.filename("config.json".to_string())
.send()
.await?;
let config_json = tokio::fs::read_to_string(config_path).await?;
Ok(Some(serde_json::from_str(&config_json)?))
}
fn mlx_variants_from_model_info(
repo_id: &str,
info: &ModelInfo,
mlx_config: &Option<serde_json::Value>,
) -> Vec<HfModelVariant> {
let siblings = info.siblings.as_deref().unwrap_or(&[]);
if !is_mlx_compatible_repo(&info.config, siblings) {
return Vec::new();
}
let size_bytes = mlx_download_filenames(siblings)
.into_iter()
.filter_map(|filename| {
siblings
.iter()
.find(|s| s.rfilename == filename)
.and_then(|s| s.size)
})
.sum();
let size_bytes = mlx_download_size_bytes(info, siblings);
let variant_id = mlx_variant_id(repo_id, &info.config);
vec![HfModelVariant {
@ -1601,20 +1743,24 @@ fn mlx_variants_from_model_info(repo_id: &str, info: &ModelInfo) -> Vec<HfModelV
size_bytes,
filename: None,
download_url: None,
description: mlx_variant_description(&info.config),
description: mlx_variant_description(mlx_config),
quality_rank: 91,
sharded: siblings
.iter()
.filter(|s| s.rfilename.ends_with(".safetensors"))
.count()
> 1,
supported: is_mlx_runtime_supported(&info.config)
supported: is_mlx_runtime_supported(mlx_config)
&& cfg!(target_os = "macos")
&& cfg!(feature = "mlx"),
unsupported_reason: mlx_unsupported_reason(&info.config),
unsupported_reason: mlx_unsupported_reason(mlx_config),
}]
}
fn is_mlx_compatible_model_info(info: &ModelInfo) -> bool {
is_mlx_compatible_repo(&info.config, info.siblings.as_deref().unwrap_or_default())
}
fn is_mlx_compatible_repo(config: &Option<serde_json::Value>, siblings: &[RepoSibling]) -> bool {
let has_config = siblings.iter().any(|s| s.rfilename == "config.json");
let has_tokenizer = has_mlx_tokenizer(siblings);
@ -1625,16 +1771,50 @@ fn is_mlx_compatible_repo(config: &Option<serde_json::Value>, siblings: &[RepoSi
has_config && has_tokenizer && has_safetensors && mlx_model_type(config).is_some()
}
fn mlx_download_size_bytes(info: &ModelInfo, siblings: &[RepoSibling]) -> u64 {
let sibling_size: u64 = mlx_download_filenames(siblings)
.into_iter()
.filter_map(|filename| {
siblings
.iter()
.find(|s| s.rfilename == filename)
.and_then(|s| s.size)
})
.sum();
sibling_size.max(estimated_safetensors_size_bytes(info))
}
fn estimated_safetensors_size_bytes(info: &ModelInfo) -> u64 {
info.safetensors
.as_ref()
.map(|safetensors| {
safetensors
.parameters
.iter()
.map(|(dtype, count)| count.saturating_mul(dtype_size_bytes(dtype)))
.sum()
})
.unwrap_or(0)
}
fn dtype_size_bytes(dtype: &str) -> u64 {
match dtype.to_ascii_uppercase().as_str() {
"BOOL" | "I8" | "U8" | "F8_E4M3" | "F8_E4M3FN" | "F8_E5M2" | "F8_E5M2FNUZ" => 1,
"BF16" | "F16" | "I16" | "U16" => 2,
"F32" | "I32" | "U32" => 4,
"F64" | "I64" | "U64" => 8,
_ => 0,
}
}
fn has_mlx_tokenizer(siblings: &[RepoSibling]) -> bool {
let has_file = |filename: &str| siblings.iter().any(|s| s.rfilename == filename);
siblings
.iter()
.any(|s| is_standalone_mlx_tokenizer_file(&s.rfilename))
|| (has_file("vocab.json") && has_file("merges.txt"))
}
fn is_standalone_mlx_tokenizer_file(filename: &str) -> bool {
filename == "tokenizer.json" || filename.ends_with(".model") || filename.ends_with(".tiktoken")
filename == "tokenizer.json"
}
fn mlx_model_type(config: &Option<serde_json::Value>) -> Option<&str> {
@ -1644,22 +1824,8 @@ fn mlx_model_type(config: &Option<serde_json::Value>) -> Option<&str> {
.and_then(|value| value.as_str())
}
fn is_mlx_runtime_supported_model_type(model_type: &str) -> bool {
matches!(model_type, "gemma4" | "gemma4_text" | "llama" | "qwen3")
}
fn is_mlx_moe_model(config: &Option<serde_json::Value>) -> bool {
config
.as_ref()
.and_then(|config| config.get("text_config"))
.and_then(|text_config| text_config.get("enable_moe_block"))
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
fn is_mlx_runtime_supported(config: &Option<serde_json::Value>) -> bool {
mlx_model_type(config).is_some_and(is_mlx_runtime_supported_model_type)
&& !is_mlx_moe_model(config)
mlx_config_support(config).is_none()
}
fn mlx_unsupported_reason(config: &Option<serde_json::Value>) -> Option<String> {
@ -1670,16 +1836,23 @@ fn mlx_unsupported_reason(config: &Option<serde_json::Value>) -> Option<String>
return Some("MLX support was not compiled in".to_string());
}
let model_type = mlx_model_type(config)?;
if !is_mlx_runtime_supported_model_type(model_type) {
return Some(format!(
"MLX backend does not support '{}' models yet",
model_type
));
}
if is_mlx_moe_model(config) {
return Some("MLX backend does not support Gemma 4 MoE models yet".to_string());
}
mlx_config_support(config)
}
fn mlx_config_support(config: &Option<serde_json::Value>) -> Option<String> {
let config = config.as_ref()?;
mlx_config_support_for_value(config)
}
#[cfg(feature = "mlx")]
fn mlx_config_support_for_value(config: &serde_json::Value) -> Option<String> {
safemlx_lm::check_model_config(config)
.unsupported_reason()
.map(str::to_string)
}
#[cfg(not(feature = "mlx"))]
fn mlx_config_support_for_value(_config: &serde_json::Value) -> Option<String> {
None
}
@ -1771,6 +1944,10 @@ fn should_download_for_mlx(filename: &str) -> bool {
|| is_standalone_mlx_tokenizer_file(filename)
|| filename == "tokenizer_config.json"
|| filename == "generation_config.json"
|| filename == "configuration.json"
|| filename == "chat_template.jinja"
|| filename == "preprocessor_config.json"
|| filename == "video_preprocessor_config.json"
|| filename == "special_tokens_map.json"
|| filename == "model.safetensors.index.json"
|| filename == "vocab.json"
@ -1780,7 +1957,7 @@ fn should_download_for_mlx(filename: &str) -> bool {
fn mlx_variant_id(repo_id: &str, config: &Option<serde_json::Value>) -> String {
let repo_lower = repo_id.to_lowercase();
for marker in ["bf16", "f16", "fp16", "f32", "fp32", "4bit", "8bit"] {
for marker in ["bf16", "f16", "fp16", "f32", "fp32", "fp8", "4bit", "8bit"] {
if repo_lower.contains(marker) {
return marker.to_string();
}
@ -1954,20 +2131,12 @@ async fn resolve_mlx_model(repo_id: &str, variant_id: &str) -> Result<ResolvedLo
let repo = client.model(owner.to_string(), name.to_string());
let info = repo
.info()
.expand(vec!["siblings".to_string()])
.expand(vec!["siblings".to_string(), "safetensors".to_string()])
.send()
.await?;
let siblings = info.siblings.as_deref().unwrap_or(&[]);
let filenames = mlx_download_filenames(siblings);
let total_size = filenames
.iter()
.filter_map(|filename| {
siblings
.iter()
.find(|s| s.rfilename == *filename)
.and_then(|s| s.size)
})
.sum();
let total_size = mlx_download_size_bytes(&info, siblings);
let progress = HfDownloadProgress::new(repo_id.to_string(), total_size);
progress.init();
let mut snapshot_path = None;
@ -2103,8 +2272,13 @@ impl HfDownloadProgress {
}
fn finish_file(&self, size_bytes: u64) {
let observed_size = self
.state
.lock()
.map(|state| state.current_file_total_bytes.max(state.bytes_downloaded))
.unwrap_or(0);
if let Ok(mut completed_bytes) = self.completed_bytes.lock() {
*completed_bytes = completed_bytes.saturating_add(size_bytes);
*completed_bytes = completed_bytes.saturating_add(size_bytes.max(observed_size));
}
if let Ok(mut state) = self.state.lock() {
state.bytes_downloaded = 0;
@ -2117,10 +2291,15 @@ impl HfDownloadProgress {
let completed_bytes = self.completed_bytes.lock().map(|value| *value).unwrap_or(0);
if let Ok(state) = self.state.lock() {
let bytes_downloaded = completed_bytes.saturating_add(state.bytes_downloaded);
let total_bytes =
self.total_bytes
.max(completed_bytes.saturating_add(
state.current_file_total_bytes.max(state.bytes_downloaded),
));
update_download_manager_progress(
&self.model_id,
bytes_downloaded.min(self.total_bytes),
self.total_bytes.max(state.current_file_total_bytes),
bytes_downloaded.min(total_bytes),
total_bytes,
state.speed_bps,
);
}
@ -2152,10 +2331,12 @@ impl ProgressHandler for HfDownloadProgress {
file.bytes_completed
}
})
.max()
.unwrap_or(0);
.sum();
let total_bytes = files.iter().map(|file| file.total_bytes).sum();
if let Ok(mut state) = self.state.lock() {
state.bytes_downloaded = state.bytes_downloaded.max(bytes_downloaded);
state.current_file_total_bytes =
state.current_file_total_bytes.max(total_bytes);
}
self.update_progress_from_state();
}
@ -2168,7 +2349,7 @@ impl ProgressHandler for HfDownloadProgress {
return;
}
if let Ok(mut state) = self.state.lock() {
state.bytes_downloaded = *bytes_completed;
state.bytes_downloaded = state.bytes_downloaded.max(*bytes_completed);
state.current_file_total_bytes =
(*total_bytes).max(state.current_file_total_bytes);
state.speed_bps = bytes_per_sec.map(|speed| speed as u64);

View file

@ -14,6 +14,7 @@ mod mlx;
pub(crate) mod multimodal;
#[cfg(feature = "mlx")]
mod native_tool_parsing;
pub(crate) mod thinking_output;
#[cfg(feature = "mlx")]
mod tool_emulation;
mod tool_parsing;
@ -23,7 +24,9 @@ use async_stream::try_stream;
use async_trait::async_trait;
use backend::{BackendLoadedModel, LocalInferenceBackend};
use goose_provider_types::base::{MessageStream, Provider, ProviderDescriptor, ProviderMetadata};
use goose_provider_types::conversation::message::{Message, MessageContent};
use goose_provider_types::conversation::message::{
Message, MessageContent, SystemNotificationType,
};
use goose_provider_types::conversation::token_usage::{ProviderUsage, Usage};
use goose_provider_types::errors::ProviderError;
use goose_provider_types::images::ImageFormat;
@ -34,13 +37,33 @@ use local_model_registry::ChatTemplate;
use mlx::{MlxBackend, MLX_BACKEND_ID};
use rmcp::model::Tool;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex, Weak};
use tokio::sync::Mutex;
use std::sync::{Arc, Mutex as StdMutex};
use tokio::sync::{Mutex, Notify};
use uuid::Uuid;
type ModelSlot = Arc<Mutex<Option<Box<dyn BackendLoadedModel>>>>;
type ModelSlotHandle = Arc<ModelSlot>;
struct ModelSlot {
state: Mutex<ModelSlotState>,
notify: Notify,
}
enum ModelSlotState {
Empty,
Loading,
Loaded(Box<dyn BackendLoadedModel>),
}
impl ModelSlot {
fn new() -> Self {
Self {
state: Mutex::new(ModelSlotState::Empty),
notify: Notify::new(),
}
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct ModelCacheKey {
@ -64,7 +87,8 @@ impl ModelCacheKey {
}
pub struct InferenceRuntime {
models: StdMutex<HashMap<ModelCacheKey, ModelSlot>>,
models: StdMutex<HashMap<ModelCacheKey, ModelSlotHandle>>,
cold_load_lock: Mutex<()>,
backends: HashMap<&'static str, Arc<dyn LocalInferenceBackend>>,
}
@ -72,19 +96,17 @@ pub fn builtin_chat_template_names() -> Vec<String> {
llamacpp::builtin_chat_template_names()
}
/// Global weak reference used to share a single `InferenceRuntime` across
/// all providers and management APIs. Only a `Weak` is stored here — strong
/// `Arc`s live in providers and the local-inference management layer. When all
/// strong refs drop (normal shutdown), the runtime is deallocated and the
/// backend freed. The `Weak` left behind is inert during `__cxa_finalize`, so no
/// ggml statics race.
static RUNTIME: StdMutex<Weak<InferenceRuntime>> = StdMutex::new(Weak::new());
static RUNTIME: StdMutex<Option<Arc<InferenceRuntime>>> = StdMutex::new(None);
fn current_runtime() -> Option<Arc<InferenceRuntime>> {
RUNTIME.lock().expect("runtime lock poisoned").clone()
}
impl InferenceRuntime {
pub fn get_or_init() -> Result<Arc<Self>> {
let mut guard = RUNTIME.lock().expect("runtime lock poisoned");
if let Some(runtime) = guard.upgrade() {
return Ok(runtime);
if let Some(runtime) = guard.as_ref() {
return Ok(runtime.clone());
}
let llamacpp_backend: Arc<dyn LocalInferenceBackend> = Arc::new(LlamaCppBackend::new()?);
let mlx_backend: Arc<dyn LocalInferenceBackend> = Arc::new(MlxBackend::new());
@ -93,9 +115,10 @@ impl InferenceRuntime {
backends.insert(MLX_BACKEND_ID, mlx_backend);
let runtime = Arc::new(Self {
models: StdMutex::new(HashMap::new()),
cold_load_lock: Mutex::new(()),
backends,
});
*guard = Arc::downgrade(&runtime);
*guard = Some(runtime.clone());
Ok(runtime)
}
@ -122,14 +145,19 @@ impl InferenceRuntime {
})
}
fn get_or_create_model_slot(&self, key: ModelCacheKey) -> ModelSlot {
fn get_or_create_model_slot(&self, key: ModelCacheKey) -> ModelSlotHandle {
let mut map = self.models.lock().expect("model cache lock poisoned");
map.entry(key)
.or_insert_with(|| Arc::new(Mutex::new(None)))
.or_insert_with(|| Arc::new(ModelSlot::new()))
.clone()
}
fn other_model_slots(&self, keep_key: &ModelCacheKey) -> Vec<ModelSlot> {
fn model_slot(&self, key: &ModelCacheKey) -> Option<ModelSlotHandle> {
let map = self.models.lock().expect("model cache lock poisoned");
map.get(key).cloned()
}
fn other_model_slots(&self, keep_key: &ModelCacheKey) -> Vec<ModelSlotHandle> {
let map = self.models.lock().expect("model cache lock poisoned");
map.iter()
.filter(|(key, _)| *key != keep_key)
@ -138,6 +166,76 @@ impl InferenceRuntime {
}
}
pub async fn is_model_loaded(model_name: &str) -> Result<bool, ProviderError> {
let resolved = match resolve_model_path(model_name) {
Some(resolved) => resolved,
None => return Ok(false),
};
let runtime = InferenceRuntime::get_or_init().map_err(|error| {
ProviderError::ExecutionError(format!("Failed to initialize local inference: {error}"))
})?;
let backend = runtime.backend_for_model(&resolved)?;
let key = ModelCacheKey::new(
backend.id(),
model_name.to_string(),
resolved.settings.chat_template,
);
let Some(slot) = runtime.model_slot(&key) else {
return Ok(false);
};
let state = slot.state.lock().await;
Ok(matches!(*state, ModelSlotState::Loaded(_)))
}
pub async fn loaded_model_ids() -> Result<HashSet<String>, ProviderError> {
let Some(runtime) = current_runtime() else {
return Ok(HashSet::new());
};
let slots = {
let map = runtime.models.lock().expect("model cache lock poisoned");
map.iter()
.map(|(key, slot)| (key.model_id.clone(), slot.clone()))
.collect::<Vec<_>>()
};
let mut loaded = HashSet::new();
for (model_id, slot) in slots {
if let Ok(state) = slot.state.try_lock() {
if matches!(*state, ModelSlotState::Loaded(_)) {
loaded.insert(model_id);
}
} else {
loaded.insert(model_id);
}
}
Ok(loaded)
}
pub async fn evict_model(model_name: &str) -> Result<bool, ProviderError> {
let Some(runtime) = current_runtime() else {
return Ok(false);
};
let slots = {
let map = runtime.models.lock().expect("model cache lock poisoned");
map.iter()
.filter(|(key, _)| key.model_id == model_name)
.map(|(_, slot)| slot.clone())
.collect::<Vec<_>>()
};
let mut evicted = false;
for slot in slots {
let mut state = slot.state.lock().await;
if matches!(*state, ModelSlotState::Loaded(_)) {
*state = ModelSlotState::Empty;
evicted = true;
slot.notify.notify_waiters();
}
}
Ok(evicted)
}
const PROVIDER_NAME: &str = "local";
const DEFAULT_MODEL: &str = "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M";
@ -565,41 +663,9 @@ 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(),
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.
{
let mut model_lock = model_slot.lock().await;
if model_lock.is_none() {
for slot in self.runtime.other_model_slots(&cache_key) {
let mut other = slot.lock().await;
if other.is_some() {
tracing::info!("Unloading previous model to free memory");
*other = None;
}
}
let model_id = model_config.model_name.clone();
let resolved_for_load = resolved.clone();
let settings_for_load = model_settings.clone();
let backend_for_load = backend.clone();
let loaded = tokio::task::spawn_blocking(move || {
backend_for_load.load_model(&model_id, &resolved_for_load, &settings_for_load)
})
.await
.map_err(|e| ProviderError::ExecutionError(e.to_string()))??;
*model_lock = Some(loaded);
}
}
// Allow request_params to override thinking
let mut model_settings = model_settings;
let mut model_settings = resolved.settings.clone();
if let Some(false) = model_config
.request_param::<bool>("enable_thinking")
.or_else(|| {
@ -611,6 +677,15 @@ impl Provider for LocalInferenceProvider {
model_settings.enable_thinking = false;
}
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());
let runtime = self.runtime.clone();
let cache_key = cache_key.clone();
let model_arc = model_slot.clone();
let backend = backend.clone();
let model_name = model_config.model_name.clone();
@ -639,17 +714,172 @@ impl Provider for LocalInferenceProvider {
},
});
let mut log = start_log(model_config, &log_payload)?;
let (tx, mut rx) = tokio::sync::mpsc::channel::<
Result<(Option<Message>, Option<ProviderUsage>), ProviderError>,
>(32);
let mut log = start_log(model_config, &log_payload)?;
tokio::task::spawn_blocking(move || {
// Macro to log errors before sending them through the channel
macro_rules! send_err {
($err:expr) => {{
let err = $err;
tokio::spawn(async move {
let mut model_load_ms = None;
// Ensure model is loaded — unload any other models first to free memory.
loop {
let state = model_slot.state.lock().await;
match &*state {
ModelSlotState::Loaded(_) => break,
ModelSlotState::Loading => {
let notified = model_slot.notify.notified();
drop(state);
notified.await;
}
ModelSlotState::Empty => {
drop(state);
let cold_load_guard = runtime.cold_load_lock.lock().await;
let mut state = model_slot.state.lock().await;
match &*state {
ModelSlotState::Loaded(_) => break,
ModelSlotState::Loading => {
let notified = model_slot.notify.notified();
drop(state);
drop(cold_load_guard);
notified.await;
continue;
}
ModelSlotState::Empty => {}
}
*state = ModelSlotState::Loading;
drop(state);
let loading_message = Message::assistant().with_system_notification(
SystemNotificationType::ProgressMessage,
format!("Loading local model {model_name}..."),
);
if tx.send(Ok((Some(loading_message), None))).await.is_err() {
let mut state = model_slot.state.lock().await;
*state = ModelSlotState::Empty;
model_slot.notify.notify_waiters();
return;
}
let other_model_slots = runtime.other_model_slots(&cache_key);
for slot in other_model_slots {
let mut other = slot.state.lock().await;
if matches!(*other, ModelSlotState::Loaded(_)) {
tracing::info!("Unloading previous model to free memory");
*other = ModelSlotState::Empty;
}
}
let model_id = model_name.clone();
let resolved_for_load = resolved_model.clone();
let settings_for_load = settings.clone();
let backend_for_load = backend.clone();
let load_started = std::time::Instant::now();
let loaded = match tokio::task::spawn_blocking(move || {
backend_for_load.load_model(
&model_id,
&resolved_for_load,
&settings_for_load,
)
})
.await
{
Ok(Ok(loaded)) => loaded,
Ok(Err(err)) => {
let mut state = model_slot.state.lock().await;
*state = ModelSlotState::Empty;
model_slot.notify.notify_waiters();
let _ = log.error(&err);
let _ = tx.send(Err(err)).await;
return;
}
Err(err) => {
let mut state = model_slot.state.lock().await;
*state = ModelSlotState::Empty;
model_slot.notify.notify_waiters();
let err = ProviderError::ExecutionError(err.to_string());
let _ = log.error(&err);
let _ = tx.send(Err(err)).await;
return;
}
};
let elapsed_ms =
u64::try_from(load_started.elapsed().as_millis()).unwrap_or(u64::MAX);
model_load_ms = Some(elapsed_ms);
tracing::info!(
backend = backend.id(),
model = %model_name,
model_load_ms = elapsed_ms,
"Loaded local inference model"
);
let _ = log.write(
&json!({
"path": "model_load",
"backend": backend.id(),
"model": &model_name,
"model_load_ms": elapsed_ms,
}),
None,
);
let mut state = model_slot.state.lock().await;
*state = ModelSlotState::Loaded(loaded);
model_slot.notify.notify_waiters();
drop(cold_load_guard);
break;
}
}
}
tokio::task::spawn_blocking(move || {
// Macro to log errors before sending them through the channel
macro_rules! send_err {
($err:expr) => {{
let err = $err;
let msg = match &err {
ProviderError::ExecutionError(s) => s.as_str(),
ProviderError::ContextLengthExceeded(s) => s.as_str(),
_ => "unknown error",
};
let _ = log.error(msg);
let _ = tx.blocking_send(Err(err));
return;
}};
}
let mut model_guard = model_arc.state.blocking_lock();
let loaded = match &mut *model_guard {
ModelSlotState::Loaded(loaded) => loaded.as_mut(),
ModelSlotState::Empty | ModelSlotState::Loading => {
send_err!(ProviderError::ExecutionError(
"Model not loaded".to_string()
));
}
};
let message_id = Uuid::new_v4().to_string();
let request = backend::LocalGenerationRequest {
model_name,
system: &system,
messages: &messages,
tools: &tools,
settings: &settings,
temperature,
max_tokens,
context_limit,
model_load_ms,
resolved_model: &resolved_model,
draft_model_path: resolved_model.draft_model_path.clone(),
message_id: &message_id,
tx: &tx,
log: &mut log,
};
let result = backend.generate(loaded, request);
if let Err(err) = result {
let msg = match &err {
ProviderError::ExecutionError(s) => s.as_str(),
ProviderError::ContextLengthExceeded(s) => s.as_str(),
@ -657,49 +887,8 @@ impl Provider for LocalInferenceProvider {
};
let _ = log.error(msg);
let _ = tx.blocking_send(Err(err));
return;
}};
}
let mut model_guard = model_arc.blocking_lock();
let loaded = match model_guard.as_mut() {
Some(l) => l,
None => {
send_err!(ProviderError::ExecutionError(
"Model not loaded".to_string()
));
}
};
let message_id = Uuid::new_v4().to_string();
let request = backend::LocalGenerationRequest {
model_name,
system: &system,
messages: &messages,
tools: &tools,
settings: &settings,
temperature,
max_tokens,
context_limit,
resolved_model: &resolved_model,
draft_model_path: resolved_model.draft_model_path.clone(),
message_id: &message_id,
tx: &tx,
log: &mut log,
};
let result = backend.generate(loaded.as_mut(), request);
if let Err(err) = result {
let msg = match &err {
ProviderError::ExecutionError(s) => s.as_str(),
ProviderError::ContextLengthExceeded(s) => s.as_str(),
_ => "unknown error",
};
let _ = log.error(msg);
let _ = tx.blocking_send(Err(err));
}
});
});
Ok(Box::pin(try_stream! {

View file

@ -27,10 +27,9 @@ use serde_json::json;
use std::borrow::Cow;
use uuid::Uuid;
use super::super::{finalize_usage, StreamSender};
use super::super::{finalize_usage, thinking_output::ThinkingOutputFilter, StreamSender};
use super::inference_engine::{
generation_loop, prepare_generation, GenerationContext, StopSuffixTrimmer,
ThinkingOutputFilter, TokenAction,
generation_loop, prepare_generation, GenerationContext, StopSuffixTrimmer, TokenAction,
};
const SHELL_TOOL: &str = "developer__shell";

View file

@ -3,7 +3,6 @@ use crate::local_model_registry::ModelSettings;
use crate::multimodal::ExtractedImage;
use goose_provider_types::errors::ProviderError;
use goose_provider_types::request_log::{LoggerHandleExt, RequestLogHandle};
use goose_provider_types::thinking::{FilterOut, ThinkFilter};
use llama_cpp_2::context::params::LlamaContextParams;
use llama_cpp_2::llama_batch::LlamaBatch;
use llama_cpp_2::model::{AddBos, ChatTemplateResult, LlamaChatTemplate, LlamaModel};
@ -48,86 +47,6 @@ pub(super) struct PreparedGeneration<'model> {
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<String> {
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<String>,

View file

@ -6,9 +6,9 @@ use std::borrow::Cow;
use uuid::Uuid;
use super::super::finalize_usage;
use super::super::thinking_output::ThinkingOutputFilter;
use super::inference_engine::{
generation_loop, prepare_generation, GenerationContext, StopSuffixTrimmer,
ThinkingOutputFilter, TokenAction,
generation_loop, prepare_generation, GenerationContext, StopSuffixTrimmer, TokenAction,
};
pub(super) fn generate_with_native_tools(

View file

@ -26,6 +26,7 @@ use goose_sdk_types::custom_requests::{
LocalInferenceModelSettingsReadResponse, LocalInferenceModelSettingsUpdateResponse,
LocalInferenceModelsListResponse, LocalInferenceSamplingConfig, LocalInferenceToolCallingMode,
};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
@ -44,13 +45,16 @@ pub async fn list_models() -> Result<LocalInferenceModelsListResponse> {
let runtime = management_runtime()?;
let recommended_id = recommend_local_model(&runtime);
let loaded_model_ids = crate::loaded_model_ids()
.await
.map_err(|error| anyhow!(error.to_string()))?;
let registry = get_registry()
.lock()
.map_err(|_| anyhow!("Failed to acquire registry lock"))?;
let mut models: Vec<LocalInferenceModelDto> = registry
.list_models()
.iter()
.map(|entry| local_model_to_dto(entry, &recommended_id))
.map(|entry| local_model_to_dto(entry, &recommended_id, &loaded_model_ids))
.collect();
models.sort_by(|a, b| {
@ -204,6 +208,20 @@ pub fn delete_model(model_id: &str) -> Result<()> {
registry.delete_model(model_id)
}
pub fn model_exists(model_id: &str) -> Result<bool> {
let registry = get_registry()
.lock()
.map_err(|_| anyhow!("Failed to acquire registry lock"))?;
Ok(registry.get_model(model_id).is_some())
}
pub async fn evict_model(model_id: &str) -> Result<()> {
crate::evict_model(model_id)
.await
.map(|_| ())
.map_err(|error| anyhow!(error.to_string()))
}
pub fn get_model_settings(model_id: &str) -> Result<LocalInferenceModelSettingsReadResponse> {
let registry = get_registry()
.lock()
@ -412,7 +430,11 @@ pub async fn ensure_featured_models_current() -> Result<()> {
Ok(())
}
fn local_model_to_dto(entry: &LocalModelEntry, recommended_id: &str) -> LocalInferenceModelDto {
fn local_model_to_dto(
entry: &LocalModelEntry,
recommended_id: &str,
loaded_model_ids: &HashSet<String>,
) -> LocalInferenceModelDto {
let vision_capable = entry.settings.vision_capable;
LocalInferenceModelDto {
id: entry.id.clone(),
@ -422,6 +444,7 @@ fn local_model_to_dto(entry: &LocalModelEntry, recommended_id: &str) -> LocalInf
size_bytes: entry.file_size(),
status: model_download_status_to_dto(entry.download_status()),
recommended: recommended_id == entry.id,
is_loaded: loaded_model_ids.contains(&entry.id),
settings: model_settings_to_dto(&entry.settings),
vision_capable,
mmproj_status: vision_capable

View file

@ -3,23 +3,24 @@ mod imp {
use std::any::Any;
use std::path::{Path, PathBuf};
use mlx_lm::cache::ConcatKeyValueCache;
use mlx_lm::gemma4_mtp::generate_gemma4_mtp;
use mlx_lm::models::{gemma4_assistant::load_gemma4_assistant_model, LoadedModel, Model};
use mlx_lm_utils::tokenizer::{Chat, Conversation, Role};
use mlx_rs::transforms::eval;
use safemlx::transforms::eval;
use safemlx::{random, Array, Device, DeviceType, Stream};
use safemlx_lm::gemma4_mtp::generate_gemma4_mtp;
use safemlx_lm::models::{gemma4_assistant::load_gemma4_assistant_model, LoadedModel, Model};
use safemlx_lm_utils::tokenizer::{Chat, Conversation, Role, Tokenizer};
use serde_json::json;
use crate::backend::{BackendLoadedModel, LocalGenerationRequest, LocalInferenceBackend};
use crate::local_model_registry::{ModelSettings, ToolCallingMode};
use crate::native_tool_parsing::message_from_native_tool_text;
use crate::provider_utils::filter_extensions_from_system_prompt;
use crate::thinking_output::ThinkingOutputFilter;
use crate::tool_emulation::{
build_emulator_tool_description, load_tiny_model_prompt, message_for_emulator_action,
StreamingEmulatorParser, CODE_EXECUTION_TOOL,
};
use crate::{extract_text_content, ResolvedModelPaths};
use goose_provider_types::conversation::message::Message;
use goose_provider_types::conversation::message::{Message, MessageContent};
use goose_provider_types::conversation::token_usage::{
DraftStats, ProviderStats, ProviderUsage, Usage,
};
@ -57,14 +58,25 @@ mod imp {
}
let model_dir = model_dir_from_path(&resolved.model_path)?;
let model = LoadedModel::load(&model_dir).map_err(mlx_error)?;
let stream = Stream::new_with_device(&Device::new(DeviceType::Gpu, 0));
let weights_stream = Stream::new_with_device(&Device::new(DeviceType::Cpu, 0));
let model =
LoadedModel::load(&model_dir, &stream, &weights_stream).map_err(mlx_error)?;
let tokenizer =
Tokenizer::from_file(model_dir.join("tokenizer.json")).map_err(mlx_error)?;
tracing::info!(
backend = self.id(),
model_id,
model_type = model.model_type(),
"MLX model loaded successfully"
);
Ok(Box::new(MlxLoadedModel { model, model_dir }))
let stop_token_ids = mlx_stop_token_ids(&model, &model_dir);
Ok(Box::new(MlxLoadedModel {
model,
tokenizer,
model_dir,
stop_token_ids,
}))
}
fn generate(
@ -79,6 +91,7 @@ mod imp {
ProviderError::ExecutionError("Loaded model backend mismatch".to_string())
})?;
let stream = Stream::new_with_device(&Device::new(DeviceType::Gpu, 0));
let tool_mode = if request.tools.is_empty() {
ToolMode::None
} else {
@ -110,77 +123,114 @@ mod imp {
let prompt_array = loaded
.model
.encode_to_array(&prompt, false)
.encode_to_array(&prompt, false, &stream)
.map_err(mlx_error)?;
let max_tokens = request
.settings
.max_output_tokens
.or_else(|| {
request
.max_tokens
.and_then(|tokens| usize::try_from(tokens).ok())
})
.unwrap_or(512);
let temp = request
.temperature
.unwrap_or_else(|| temperature(request.settings));
let eos_token_ids = loaded.model.eos_token_ids().to_vec();
let max_tokens = mlx_max_tokens(
request.settings,
request.max_tokens,
request.context_limit,
prompt_tokens.len(),
);
let (settings_temp, seed) = sampling(request.settings);
let temp = request.temperature.unwrap_or(settings_temp);
let prng_key = prng_key(temp, seed)?;
let eos_token_ids = loaded.stop_token_ids.clone();
let generation_started = std::time::Instant::now();
let (generated_ids, draft_stats) =
if let Some(draft_model_path) = &request.draft_model_path {
if matches!(loaded.model.model_mut(), Model::Gemma4(_)) {
let mut assistant =
load_gemma4_assistant_model(draft_model_path).map_err(|error| {
let MlxGeneration {
generated_ids,
generated_text,
draft_stats,
time_to_first_token_ms,
streamed_response,
} = if let Some(draft_model_path) = &request.draft_model_path {
if matches!(loaded.model.model_mut(), Model::Gemma4(_)) {
let weights_stream = Stream::new_with_device(&Device::new(DeviceType::Cpu, 0));
let mut assistant =
load_gemma4_assistant_model(draft_model_path, &stream, &weights_stream)
.map_err(|error| {
mlx_error(format!("failed to load MLX draft model: {error}"))
})?;
let target = match loaded.model.model_mut() {
Model::Gemma4(target) => target,
_ => unreachable!(),
};
let (ids, stats) = generate_gemma4_mtp(
target,
&mut assistant,
&prompt_array,
&eos_token_ids,
max_tokens,
temp,
)
.map_err(mlx_error)?;
(
ids,
Some(DraftStats {
model: Some(draft_model_path.display().to_string()),
draft_tokens: stats.draft_tokens,
accepted_tokens: stats.accepted_tokens,
target_tokens: stats.target_tokens,
rounds: stats.rounds,
accept_rate: stats.accept_rate(),
}),
)
} else {
generate_single_model(
&mut loaded.model,
&prompt_array,
&eos_token_ids,
max_tokens,
temp,
)?
}
} else {
generate_single_model(
&mut loaded.model,
let target = match loaded.model.model_mut() {
Model::Gemma4(target) => target,
_ => unreachable!(),
};
let (ids, stats) = generate_gemma4_mtp(
target,
&mut assistant,
&prompt_array,
&eos_token_ids,
max_tokens,
temp,
prng_key,
&stream,
)
.map_err(mlx_error)?;
let generated_text = loaded.tokenizer.decode(&ids, true).map_err(mlx_error)?;
MlxGeneration {
generated_ids: ids,
generated_text,
draft_stats: Some(DraftStats {
model: Some(draft_model_path.display().to_string()),
draft_tokens: stats.draft_tokens,
accepted_tokens: stats.accepted_tokens,
target_tokens: stats.target_tokens,
rounds: stats.rounds,
accept_rate: stats.accept_rate(),
}),
time_to_first_token_ms: None,
streamed_response: false,
}
} else {
generate_single_model(
&mut loaded.model,
&loaded.tokenizer,
&prompt_array,
&eos_token_ids,
max_tokens,
temp,
prng_key,
&stream,
generation_started,
MlxStreamEmitter::new(
request.message_id,
tool_mode,
request.settings.enable_thinking,
&prompt,
request.tx,
),
)?
};
}
} else {
generate_single_model(
&mut loaded.model,
&loaded.tokenizer,
&prompt_array,
&eos_token_ids,
max_tokens,
temp,
prng_key,
&stream,
generation_started,
MlxStreamEmitter::new(
request.message_id,
tool_mode,
request.settings.enable_thinking,
&prompt,
request.tx,
),
)?
};
let generated_text = loaded
.model
.decode(&generated_ids, true)
.map_err(mlx_error)?;
emit_generated_response(&generated_text, request.message_id, tool_mode, request.tx)?;
if !streamed_response {
emit_generated_response(
&generated_text,
&prompt,
request.settings.enable_thinking,
request.message_id,
tool_mode,
request.tx,
)?;
}
let output_tokens = generated_ids.len() as i32;
let input_tokens = prompt_tokens.len() as i32;
@ -194,12 +244,16 @@ mod imp {
"model_dir": loaded.model_dir,
"prompt_tokens": input_tokens,
"output_tokens": output_tokens,
"model_load_ms": request.model_load_ms,
"time_to_first_token_ms": time_to_first_token_ms,
"elapsed_ms": generation_started.elapsed().as_millis() as u64,
"generated_text": generated_text,
"draft": draft_stats,
});
let _ = request.log.write(&log_json, Some(&usage));
let stats = ProviderStats {
time_to_first_token_ms: None,
time_to_first_token_ms,
model_load_ms: request.model_load_ms,
elapsed_ms: Some(generation_started.elapsed().as_millis() as u64),
output_tokens: Some(generated_ids.len()),
draft: draft_stats,
@ -221,9 +275,19 @@ mod imp {
Emulated { code_mode_enabled: bool },
}
struct MlxGeneration {
generated_ids: Vec<u32>,
generated_text: String,
draft_stats: Option<DraftStats>,
time_to_first_token_ms: Option<u64>,
streamed_response: bool,
}
struct MlxLoadedModel {
model: LoadedModel,
tokenizer: Tokenizer,
model_dir: PathBuf,
stop_token_ids: Vec<u32>,
}
impl BackendLoadedModel for MlxLoadedModel {
@ -242,6 +306,44 @@ mod imp {
}
}
fn mlx_stop_token_ids(model: &LoadedModel, model_dir: &Path) -> Vec<u32> {
let mut ids = model.eos_token_ids().to_vec();
for id in generation_config_eos_token_ids(model_dir) {
if !ids.contains(&id) {
ids.push(id);
}
}
ids
}
fn generation_config_eos_token_ids(model_dir: &Path) -> Vec<u32> {
let Ok(config_json) = std::fs::read_to_string(model_dir.join("generation_config.json"))
else {
return Vec::new();
};
let Ok(config) = serde_json::from_str::<serde_json::Value>(&config_json) else {
return Vec::new();
};
match config.get("eos_token_id") {
Some(value) => token_id_or_ids(value),
None => Vec::new(),
}
}
fn token_id_or_ids(value: &serde_json::Value) -> Vec<u32> {
if let Some(id) = value.as_u64().and_then(|id| u32::try_from(id).ok()) {
return vec![id];
}
value
.as_array()
.map(|ids| {
ids.iter()
.filter_map(|id| id.as_u64().and_then(|id| u32::try_from(id).ok()))
.collect()
})
.unwrap_or_default()
}
fn build_prompt(
model: &mut LoadedModel,
model_name: &str,
@ -316,28 +418,107 @@ mod imp {
fn generate_single_model(
model: &mut LoadedModel,
prompt_array: &mlx_rs::Array,
tokenizer: &Tokenizer,
prompt_array: &Array,
eos_token_ids: &[u32],
max_tokens: usize,
temp: f32,
) -> Result<(Vec<u32>, Option<DraftStats>), ProviderError> {
let mut cache: Vec<Option<ConcatKeyValueCache>> = Vec::new();
prng_key: Option<Array>,
stream: &Stream,
generation_started: std::time::Instant,
mut emitter: MlxStreamEmitter<'_>,
) -> Result<MlxGeneration, ProviderError> {
let mut cache = model.new_cache();
let mut generated_ids = Vec::new();
let mut streamed_text = String::new();
let mut time_to_first_token_ms = None;
let stream_generation = emitter.can_stream();
let mut decode_stream = tokenizer.decode_stream(true);
{
let generator = model
.generate(&mut cache, temp, prompt_array)
.generate_with_cache(&mut cache, temp, prompt_array, prng_key, stream)
.take(max_tokens);
for token in generator {
let token = token.map_err(mlx_error)?;
eval([&token]).map_err(mlx_error)?;
let token_id = token.item::<u32>();
let token_id = token.item::<u32>(stream);
time_to_first_token_ms.get_or_insert_with(|| {
u64::try_from(generation_started.elapsed().as_millis()).unwrap_or(u64::MAX)
});
if eos_token_ids.contains(&token_id) {
break;
}
generated_ids.push(token_id);
if stream_generation {
if let Some(piece) = decode_stream.step(token_id).map_err(mlx_error)? {
if !piece.is_empty() {
let should_continue = emitter.push_text(&piece)?;
streamed_text.push_str(&piece);
if !should_continue {
break;
}
}
}
}
}
}
Ok((generated_ids, None))
let generated_text = tokenizer.decode(&generated_ids, true).map_err(mlx_error)?;
let streamed_response = if stream_generation {
match final_stream_suffix(&generated_text, &streamed_text)? {
Some(suffix) => {
if !suffix.is_empty() {
emitter.push_text(suffix)?;
}
true
}
None => false,
}
} else {
false
};
if streamed_response {
emitter.finish()?;
}
Ok(MlxGeneration {
generated_ids,
generated_text,
draft_stats: None,
time_to_first_token_ms,
streamed_response,
})
}
fn final_stream_suffix<'a>(
generated_text: &'a str,
streamed_text: &str,
) -> Result<Option<&'a str>, ProviderError> {
if streamed_text.is_empty() {
return Ok(None);
}
generated_text
.strip_prefix(streamed_text)
.map(Some)
.ok_or_else(|| mlx_error("streamed MLX decode did not match final tokenizer decode"))
}
fn mlx_max_tokens(
settings: &ModelSettings,
request_max_tokens: Option<i32>,
context_limit: usize,
prompt_tokens: usize,
) -> usize {
let configured_max = settings
.max_output_tokens
.or_else(|| request_max_tokens.and_then(|tokens| usize::try_from(tokens).ok()));
if context_limit == 0 {
return configured_max.unwrap_or(4096);
}
let context_headroom = context_limit.saturating_sub(prompt_tokens);
configured_max
.map(|max| max.min(context_headroom))
.unwrap_or(context_headroom)
}
fn is_gemma4(model: &LoadedModel) -> bool {
@ -463,6 +644,8 @@ mod imp {
fn emit_generated_response(
generated_text: &str,
generation_prompt: &str,
enable_thinking: bool,
message_id: &str,
tool_mode: ToolMode,
tx: &tokio::sync::mpsc::Sender<
@ -473,30 +656,27 @@ mod imp {
return Ok(());
}
let (content, thinking) =
split_generated_thinking(generated_text, generation_prompt, enable_thinking);
match tool_mode {
ToolMode::None => {
let mut msg = Message::assistant().with_text(generated_text);
msg.id = Some(message_id.to_string());
tx.blocking_send(Ok((Some(msg), None))).map_err(|_| {
ProviderError::ExecutionError("Failed to stream MLX response".to_string())
})?;
emit_assistant_message(message_id, &thinking, &content, tx)?;
}
ToolMode::Native => {
if let Some(message) = message_from_native_tool_text(generated_text, message_id)? {
if let Some(mut message) = message_from_native_tool_text(&content, message_id)? {
prepend_thinking(&mut message, &thinking);
tx.blocking_send(Ok((Some(message), None))).map_err(|_| {
ProviderError::ExecutionError("Failed to stream MLX response".to_string())
})?;
} else {
let mut msg = Message::assistant().with_text(generated_text);
msg.id = Some(message_id.to_string());
tx.blocking_send(Ok((Some(msg), None))).map_err(|_| {
ProviderError::ExecutionError("Failed to stream MLX response".to_string())
})?;
emit_assistant_message(message_id, &thinking, &content, tx)?;
}
}
ToolMode::Emulated { code_mode_enabled } => {
emit_assistant_message(message_id, &thinking, "", tx)?;
let mut parser = StreamingEmulatorParser::new(code_mode_enabled);
let mut actions = parser.process_chunk(generated_text);
let mut actions = parser.process_chunk(&content);
actions.extend(parser.flush());
for action in actions {
@ -510,14 +690,191 @@ mod imp {
Ok(())
}
fn temperature(settings: &ModelSettings) -> f32 {
match &settings.sampling {
crate::local_model_registry::SamplingConfig::Greedy => 0.0,
crate::local_model_registry::SamplingConfig::Temperature { temperature, .. } => {
*temperature
struct MlxStreamEmitter<'a> {
message_id: &'a str,
tool_mode: ToolMode,
tx: &'a tokio::sync::mpsc::Sender<
Result<(Option<Message>, Option<ProviderUsage>), ProviderError>,
>,
output_filter: ThinkingOutputFilter,
emulator_parser: Option<StreamingEmulatorParser>,
stop_after_tool_call: bool,
}
impl<'a> MlxStreamEmitter<'a> {
fn new(
message_id: &'a str,
tool_mode: ToolMode,
enable_thinking: bool,
generation_prompt: &str,
tx: &'a tokio::sync::mpsc::Sender<
Result<(Option<Message>, Option<ProviderUsage>), ProviderError>,
>,
) -> Self {
let emulator_parser = match tool_mode {
ToolMode::Emulated { code_mode_enabled } => {
Some(StreamingEmulatorParser::new(code_mode_enabled))
}
ToolMode::None | ToolMode::Native => None,
};
Self {
message_id,
tool_mode,
tx,
output_filter: ThinkingOutputFilter::new(enable_thinking, generation_prompt),
emulator_parser,
stop_after_tool_call: false,
}
crate::local_model_registry::SamplingConfig::MirostatV2 { .. } => 0.0,
}
fn can_stream(&self) -> bool {
!matches!(self.tool_mode, ToolMode::Native)
}
fn push_text(&mut self, text: &str) -> Result<bool, ProviderError> {
let filtered = self.output_filter.push_text(text);
if !filtered.content.is_empty() {
self.emit_content(&filtered.content)?;
}
Ok(!self.stop_after_tool_call)
}
fn finish(&mut self) -> Result<(), ProviderError> {
self.flush_filtered_output()?;
let actions = self
.emulator_parser
.as_mut()
.map(StreamingEmulatorParser::flush)
.unwrap_or_default();
for action in actions {
let (message, is_tool) = message_for_emulator_action(&action, self.message_id);
if is_tool {
self.flush_filtered_output()?;
}
self.send(message)?;
self.stop_after_tool_call |= is_tool;
if is_tool {
break;
}
}
Ok(())
}
fn flush_filtered_output(&mut self) -> Result<(), ProviderError> {
let filtered = self.output_filter.finish();
if !filtered.thinking.is_empty() {
let mut message = Message::assistant().with_thinking(filtered.thinking, "");
message.id = Some(self.message_id.to_string());
self.send(message)?;
}
if !filtered.content.is_empty() {
self.emit_content(&filtered.content)?;
}
Ok(())
}
fn emit_content(&mut self, content: &str) -> Result<(), ProviderError> {
match self.tool_mode {
ToolMode::None => {
let mut message = Message::assistant().with_text(content);
message.id = Some(self.message_id.to_string());
self.send(message)
}
ToolMode::Emulated { .. } => {
let actions = self
.emulator_parser
.as_mut()
.map(|parser| parser.process_chunk(content))
.unwrap_or_default();
for action in actions {
let (message, is_tool) =
message_for_emulator_action(&action, self.message_id);
if is_tool {
self.flush_filtered_output()?;
}
self.send(message)?;
self.stop_after_tool_call |= is_tool;
if is_tool {
break;
}
}
Ok(())
}
ToolMode::Native => Ok(()),
}
}
fn send(&self, message: Message) -> Result<(), ProviderError> {
self.tx
.blocking_send(Ok((Some(message), None)))
.map_err(|_| {
ProviderError::ExecutionError("Failed to stream MLX response".to_string())
})
}
}
fn split_generated_thinking(
generated_text: &str,
generation_prompt: &str,
enable_thinking: bool,
) -> (String, String) {
let mut filter = ThinkingOutputFilter::new(enable_thinking, generation_prompt);
let mut filtered = filter.push_text(generated_text);
let final_filtered = filter.finish();
filtered.content.push_str(&final_filtered.content);
filtered.thinking.push_str(&final_filtered.thinking);
(filtered.content, filtered.thinking)
}
fn emit_assistant_message(
message_id: &str,
thinking: &str,
content: &str,
tx: &tokio::sync::mpsc::Sender<
Result<(Option<Message>, Option<ProviderUsage>), ProviderError>,
>,
) -> Result<(), ProviderError> {
if thinking.is_empty() && content.is_empty() {
return Ok(());
}
let mut message = Message::assistant();
if !thinking.is_empty() {
message = message.with_thinking(thinking, "");
}
if !content.is_empty() {
message = message.with_text(content);
}
message.id = Some(message_id.to_string());
tx.blocking_send(Ok((Some(message), None)))
.map_err(|_| ProviderError::ExecutionError("Failed to stream MLX response".to_string()))
}
fn prepend_thinking(message: &mut Message, thinking: &str) {
if !thinking.is_empty() {
message
.content
.insert(0, MessageContent::thinking(thinking, ""));
}
}
fn sampling(settings: &ModelSettings) -> (f32, Option<u32>) {
match &settings.sampling {
crate::local_model_registry::SamplingConfig::Greedy => (0.0, None),
crate::local_model_registry::SamplingConfig::Temperature {
temperature, seed, ..
} => (*temperature, *seed),
crate::local_model_registry::SamplingConfig::MirostatV2 { seed, .. } => (0.0, *seed),
}
}
fn prng_key(temp: f32, seed: Option<u32>) -> Result<Option<Array>, ProviderError> {
if temp == 0.0 {
return Ok(None);
}
random::key(seed.unwrap_or(0) as u64)
.map(Some)
.map_err(mlx_error)
}
fn render_prompt(system: &str, messages: &[Message]) -> String {
@ -547,6 +904,90 @@ mod imp {
fn mlx_error(error: impl std::fmt::Display) -> ProviderError {
ProviderError::ExecutionError(format!("MLX backend error: {}", error))
}
#[cfg(test)]
mod tests {
use super::{
final_stream_suffix, mlx_max_tokens, split_generated_thinking, token_id_or_ids,
};
use crate::local_model_registry::ModelSettings;
use serde_json::json;
#[test]
fn extracts_thinking_started_by_generation_prompt() {
let (content, thinking) = split_generated_thinking(
"hidden reasoning</think>visible answer",
"<|im_start|>assistant\n<think>\n",
true,
);
assert_eq!(thinking.trim(), "hidden reasoning");
assert_eq!(content, "visible answer");
}
#[test]
fn leaves_think_tags_as_content_when_thinking_disabled() {
let generated = "hidden reasoning</think>visible answer";
let (content, thinking) =
split_generated_thinking(generated, "<|im_start|>assistant\n<think>\n", false);
assert!(thinking.is_empty());
assert_eq!(content, generated);
}
#[test]
fn parses_single_and_multiple_eos_token_ids() {
assert_eq!(token_id_or_ids(&json!(248044)), vec![248044]);
assert_eq!(
token_id_or_ids(&json!([248046, 248044])),
vec![248046, 248044]
);
}
#[test]
fn final_stream_suffix_flushes_append_only_suffix() {
assert_eq!(
final_stream_suffix("hello world", "hello").unwrap(),
Some(" world")
);
}
#[test]
fn final_stream_suffix_does_not_replay_fully_streamed_text() {
assert_eq!(
final_stream_suffix("run tool", "run tool").unwrap(),
Some("")
);
}
#[test]
fn final_stream_suffix_allows_unstreamed_fallback() {
assert_eq!(final_stream_suffix("hello world", "").unwrap(), None);
}
#[test]
fn final_stream_suffix_rejects_rewritten_streamed_prefix() {
assert!(final_stream_suffix("corrected response", "stale prefix").is_err());
}
#[test]
fn max_tokens_defaults_to_context_headroom() {
let settings = ModelSettings::default();
assert_eq!(mlx_max_tokens(&settings, None, 128_000, 1_752), 126_248);
}
#[test]
fn max_tokens_respects_configured_caps() {
let mut settings = ModelSettings::default();
settings.max_output_tokens = Some(2048);
assert_eq!(mlx_max_tokens(&settings, None, 128_000, 1_752), 2048);
let settings = ModelSettings::default();
assert_eq!(mlx_max_tokens(&settings, Some(1024), 128_000, 1_752), 1024);
}
}
}
#[cfg(not(feature = "mlx"))]

View file

@ -0,0 +1,81 @@
use goose_provider_types::thinking::{FilterOut, ThinkFilter};
pub(crate) struct ThinkingOutputFilter {
enabled: bool,
saw_structured_reasoning: bool,
think_filter: ThinkFilter,
pending_inline_thinking: String,
accumulated_thinking: String,
}
impl ThinkingOutputFilter {
pub(crate) 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(crate) fn push_structured_reasoning(&mut self, reasoning: &str) -> Option<String> {
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(crate) 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(crate) 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(crate) fn accumulated_thinking(&self) -> &str {
&self.accumulated_thinking
}
}

View file

@ -253,6 +253,7 @@ pub struct FrontendToolRequest {
#[serde(rename_all = "camelCase")]
pub enum SystemNotificationType {
ThinkingMessage,
ProgressMessage,
InlineMessage,
CreditsExhausted,
}

View file

@ -25,6 +25,7 @@ pub enum CostSource {
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderStats {
pub time_to_first_token_ms: Option<u64>,
pub model_load_ms: Option<u64>,
pub elapsed_ms: Option<u64>,
pub output_tokens: Option<usize>,
pub draft: Option<DraftStats>,

View file

@ -1910,6 +1910,7 @@ pub struct LocalInferenceModelDto {
pub size_bytes: u64,
pub status: LocalInferenceModelDownloadStatusDto,
pub recommended: bool,
pub is_loaded: bool,
pub settings: LocalInferenceModelSettingsDto,
pub vision_capable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -2031,6 +2032,16 @@ pub struct LocalInferenceModelDeleteRequest {
pub model_id: String,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(
method = "_goose/unstable/local-inference/models/evict",
response = EmptyResponse
)]
#[serde(rename_all = "camelCase")]
pub struct LocalInferenceModelEvictRequest {
pub model_id: String,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(
method = "_goose/unstable/local-inference/models/settings/read",

View file

@ -530,6 +530,11 @@
"requestType": "LocalInferenceModelDeleteRequest_unstable",
"responseType": "EmptyResponse"
},
{
"method": "_goose/unstable/local-inference/models/evict",
"requestType": "LocalInferenceModelEvictRequest_unstable",
"responseType": "EmptyResponse"
},
{
"method": "_goose/unstable/local-inference/models/settings/read",
"requestType": "LocalInferenceModelSettingsReadRequest_unstable",

View file

@ -5734,6 +5734,9 @@
"recommended": {
"type": "boolean"
},
"isLoaded": {
"type": "boolean"
},
"settings": {
"$ref": "#/$defs/LocalInferenceModelSettingsDto"
},
@ -5759,6 +5762,7 @@
"sizeBytes",
"status",
"recommended",
"isLoaded",
"settings",
"visionCapable"
]
@ -6217,6 +6221,19 @@
"x-side": "agent",
"x-method": "_goose/unstable/local-inference/models/delete"
},
"LocalInferenceModelEvictRequest_unstable": {
"type": "object",
"properties": {
"modelId": {
"type": "string"
}
},
"required": [
"modelId"
],
"x-side": "agent",
"x-method": "_goose/unstable/local-inference/models/evict"
},
"LocalInferenceModelSettingsReadRequest_unstable": {
"type": "object",
"properties": {
@ -7789,6 +7806,15 @@
"description": "Params for _goose/unstable/local-inference/models/delete",
"title": "LocalInferenceModelDeleteRequest_unstable"
},
{
"allOf": [
{
"$ref": "#/$defs/LocalInferenceModelEvictRequest_unstable"
}
],
"description": "Params for _goose/unstable/local-inference/models/evict",
"title": "LocalInferenceModelEvictRequest_unstable"
},
{
"allOf": [
{

View file

@ -2072,6 +2072,23 @@ fn send_status_message_update(
Ok(())
}
fn send_progress_message_update(
cx: &ConnectionTo<Client>,
supports_goose_custom_notifications: bool,
session_id: &str,
message: String,
) -> Result<(), agent_client_protocol::Error> {
if supports_goose_custom_notifications {
cx.send_notification(GooseSessionNotification {
session_id: session_id.to_string(),
update: GooseSessionUpdate::StatusMessage(StatusMessageUpdate {
status: StatusMessage::Progress { message },
}),
})?;
}
Ok(())
}
fn status_message_from_system_notification(
notification: &SystemNotificationContent,
) -> Option<StatusMessage> {
@ -2079,9 +2096,11 @@ fn status_message_from_system_notification(
SystemNotificationType::InlineMessage => Some(StatusMessage::Notice {
message: notification.msg.clone(),
}),
SystemNotificationType::ThinkingMessage => Some(StatusMessage::Progress {
message: notification.msg.clone(),
}),
SystemNotificationType::ThinkingMessage | SystemNotificationType::ProgressMessage => {
Some(StatusMessage::Progress {
message: notification.msg.clone(),
})
}
SystemNotificationType::CreditsExhausted => None,
}
}
@ -2482,6 +2501,44 @@ impl GooseAcpAgent {
))
}
async fn send_local_inference_progress_update(
&self,
cx: &ConnectionTo<Client>,
acp_session_id: &SessionId,
session_id: &str,
agent: &Arc<Agent>,
) -> Result<(), agent_client_protocol::Error> {
let Ok(provider) = agent.provider().await else {
return Ok(());
};
if provider.get_name() != "local" {
return Ok(());
}
let model_config = agent.model_config_for_session(session_id).await.ok();
let model_name = model_config
.as_ref()
.map(|config| config.model_name.clone())
.unwrap_or_else(|| "local model".to_string());
#[cfg(feature = "local-inference")]
if let Some(model_config) = model_config.as_ref() {
if crate::providers::local_inference::is_model_loaded(&model_config.model_name)
.await
.unwrap_or(false)
{
return Ok(());
}
}
send_progress_message_update(
cx,
self.supports_goose_custom_notifications(),
acp_session_id.0.as_ref(),
format!("Loading local model {model_name}..."),
)
}
async fn on_load_session(
&self,
cx: &ConnectionTo<Client>,
@ -2524,6 +2581,15 @@ impl GooseAcpAgent {
return Err(error);
}
if let Err(error) = self
.send_local_inference_progress_update(cx, &args.session_id, &session_id, &agent)
.await
{
self.clear_active_run(&session_id, &run_id).await;
let _ = Self::send_active_run_update(cx, &args.session_id, None);
return Err(error);
}
let user_message = Self::convert_acp_prompt_to_message(&args.prompt);
let message_text = user_message.as_concat_text();

View file

@ -216,7 +216,8 @@ impl GooseAcpAgent {
if let Some(model_id) = model_id.as_deref() {
let model_exists = entry.default_model == model_id
|| entry.models.iter().any(|model| model.id == model_id);
|| entry.models.iter().any(|model| model.id == model_id)
|| (provider_id == "local" && local_inference_model_exists(model_id)?);
if !model_exists {
return Err(agent_client_protocol::Error::invalid_params().data(format!(
"Model '{model_id}' is not available for provider '{provider_id}'"
@ -254,6 +255,20 @@ impl GooseAcpAgent {
}
}
fn local_inference_model_exists(model_id: &str) -> Result<bool, agent_client_protocol::Error> {
#[cfg(feature = "local-inference")]
{
crate::providers::local_inference::management::model_exists(model_id)
.internal_err_ctx("Failed to read local inference models")
}
#[cfg(not(feature = "local-inference"))]
{
let _ = model_id;
Ok(false)
}
}
struct PreferenceDef {
key: PreferenceKey,
config_key: &'static str,

View file

@ -875,6 +875,14 @@ impl GooseAcpAgent {
self.on_local_inference_model_delete(req).await
}
#[custom_method(LocalInferenceModelEvictRequest)]
async fn dispatch_local_inference_model_evict(
&self,
req: LocalInferenceModelEvictRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
self.on_local_inference_model_evict(req).await
}
#[custom_method(LocalInferenceModelSettingsReadRequest)]
async fn dispatch_local_inference_model_settings_read(
&self,

View file

@ -98,6 +98,26 @@ impl GooseAcpAgent {
}
}
pub(super) async fn on_local_inference_model_evict(
&self,
req: LocalInferenceModelEvictRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
#[cfg(feature = "local-inference")]
{
crate::providers::local_inference::configure_huggingface_auth();
crate::providers::local_inference::management::evict_model(&req.model_id)
.await
.invalid_params_err()?;
Ok(EmptyResponse {})
}
#[cfg(not(feature = "local-inference"))]
{
let _ = req;
Err(local_inference_unavailable())
}
}
pub(super) async fn on_local_inference_model_settings_read(
&self,
req: LocalInferenceModelSettingsReadRequest,

View file

@ -68,7 +68,7 @@ use tracing::{debug, error, info, instrument, warn};
const DEFAULT_MAX_TURNS: u32 = 1000;
const DEFAULT_STOP_HOOK_BLOCK_CAP: u32 = 8;
const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation...";
const COMPACTION_PROGRESS_TEXT: &str = "goose is compacting the conversation...";
const MAX_TURNS_MESSAGE: &str = "I've reached the maximum number of actions I can do without user input. Would you like me to continue?";
const DEFAULT_FRONTEND_INSTRUCTIONS: &str = "The following tools are provided directly by the frontend and will be executed by the frontend when called.";
@ -1769,8 +1769,8 @@ impl Agent {
yield AgentEvent::Message(
Message::assistant().with_system_notification(
SystemNotificationType::ThinkingMessage,
COMPACTION_THINKING_TEXT,
SystemNotificationType::ProgressMessage,
COMPACTION_PROGRESS_TEXT,
)
);
@ -2060,6 +2060,17 @@ impl Agent {
}
if let Some(response) = response {
if !response.content.is_empty()
&& response
.content
.iter()
.all(|content| matches!(content, MessageContent::SystemNotification(_)))
{
yield AgentEvent::Message(response);
tokio::task::yield_now().await;
continue;
}
let ToolCategorizeResult {
frontend_requests,
remaining_requests,
@ -2427,8 +2438,8 @@ impl Agent {
);
yield AgentEvent::Message(
Message::assistant().with_system_notification(
SystemNotificationType::ThinkingMessage,
COMPACTION_THINKING_TEXT,
SystemNotificationType::ProgressMessage,
COMPACTION_PROGRESS_TEXT,
)
);

View file

@ -161,6 +161,13 @@ fn fill_stream_timing(
}
}
fn message_has_timing_content(message: &Message) -> bool {
message
.content
.iter()
.any(|content| !matches!(content, MessageContent::SystemNotification(_)))
}
impl Agent {
pub async fn prepare_tools_and_prompt(
&self,
@ -345,7 +352,7 @@ impl Agent {
let (msg_opt, usage_opt) = result?;
if let Some(msg) = msg_opt {
if first_content_at.is_none() {
if first_content_at.is_none() && message_has_timing_content(&msg) {
first_content_at = Some(std::time::Instant::now());
}
accumulated_message = Some(match accumulated_message {
@ -394,7 +401,9 @@ impl Agent {
while let Some(result) = stream.next().await {
let (message, mut usage) = result?;
if message.is_some() && first_content_at.is_none() {
if first_content_at.is_none()
&& message.as_ref().is_some_and(message_has_timing_content)
{
first_content_at = Some(std::time::Instant::now());
}
if let Some(usage) = usage.as_mut() {
@ -656,7 +665,7 @@ pub fn is_tool_visible_to_model(tool: &Tool) -> bool {
mod tests {
use super::*;
use crate::config::GooseMode;
use crate::conversation::message::Message;
use crate::conversation::message::{Message, SystemNotificationType};
use crate::providers::base::Provider;
use crate::session::session_manager::SessionType;
use async_trait::async_trait;
@ -1036,6 +1045,27 @@ mod tests {
usage
}
#[test]
fn message_has_timing_content_ignores_system_notification_only_messages() {
let message = Message::assistant().with_system_notification(
SystemNotificationType::ProgressMessage,
"Loading local model test-model...",
);
assert!(!message_has_timing_content(&message));
}
#[test]
fn message_has_timing_content_counts_user_visible_messages() {
let text_message = Message::assistant().with_text("hello");
let mixed_message = Message::assistant()
.with_system_notification(SystemNotificationType::ProgressMessage, "Loading...")
.with_text("ready");
assert!(message_has_timing_content(&text_message));
assert!(message_has_timing_content(&mixed_message));
}
#[test]
fn fill_stream_timing_fills_both_fields_when_stats_absent() {
let request_started = Instant::now() - Duration::from_millis(100);

View file

@ -81,6 +81,7 @@ function snapshotWithName(name: string): AcpChatSessionSnapshot {
accumulatedTotalTokens: 0,
},
notifications: [],
progressMessage: undefined,
chatState: ChatState.Idle,
sessionLoadError: undefined,
activePromptAttemptId: null,
@ -102,6 +103,7 @@ function snapshotWithoutSession(): AcpChatSessionSnapshot {
accumulatedTotalTokens: 0,
},
notifications: [],
progressMessage: undefined,
chatState: ChatState.Idle,
sessionLoadError: undefined,
activePromptAttemptId: null,

View file

@ -108,6 +108,7 @@ function snapshotWithActivePrompt(activePromptAttemptId: string | null): AcpChat
accumulatedTotalTokens: 0,
},
notifications: [],
progressMessage: undefined,
chatState: activePromptAttemptId ? ChatState.Streaming : ChatState.Idle,
sessionLoadError: undefined,
activePromptAttemptId,

View file

@ -583,13 +583,9 @@ describe('createAcpSessionNotificationAdapter', () => {
status: { type: 'progress', message: 'Still working' },
})
);
messages = expectOnlyMessagesChange(progressStateChanges);
expect(firstContent(messages[2])).toMatchObject({
type: 'systemNotification',
notificationType: 'thinkingMessage',
msg: 'Still working',
});
expect(progressStateChanges).toEqual([
{ type: 'progressMessage', message: 'Still working' },
]);
});
});

View file

@ -38,7 +38,9 @@ function applyStatusMessage(
sessionId: string,
update: Extract<GooseSessionNotification_unstable['update'], { sessionUpdate: 'status_message' }>
): AcpChatStateChange[] {
const notificationType = update.status.type === 'notice' ? 'inlineMessage' : 'thinkingMessage';
if (update.status.type === 'progress') {
return [{ type: 'progressMessage', message: update.status.message }];
}
state.messages.push({
id: `acp_status_${sessionId}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
@ -47,7 +49,7 @@ function applyStatusMessage(
content: [
{
type: 'systemNotification',
notificationType,
notificationType: 'inlineMessage',
msg: update.status.message,
},
],

View file

@ -5,6 +5,7 @@ import type { Message, NotificationEvent } from '../../types/message';
export type AcpChatStateChange =
| { type: 'messages'; messages: Message[] }
| { type: 'tokenState'; tokenState: Partial<TokenState> }
| { type: 'progressMessage'; message: string | undefined }
| {
type: 'sessionInfo';
name?: string;

View file

@ -19,6 +19,7 @@ export interface AcpChatSessionSnapshot {
messages: Message[];
tokenState: TokenState;
notifications: NotificationEvent[];
progressMessage: string | undefined;
chatState: ChatState;
sessionLoadError: string | undefined;
activePromptAttemptId: string | null;
@ -155,6 +156,7 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal {
messages: [],
tokenState: { ...initialTokenState },
notifications: [],
progressMessage: undefined,
chatState: ChatState.Idle,
sessionLoadError: undefined,
activePromptAttemptId: null,
@ -190,6 +192,7 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal {
const entry = getOrCreateEntry(sessionId);
resetReplayState(entry);
entry.sessionLoadError = undefined;
entry.progressMessage = undefined;
entry.chatState = ChatState.LoadingConversation;
return notify(sessionId, entry);
};
@ -198,6 +201,7 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal {
const entry = getOrCreateEntry(sessionId);
entry.session = session;
entry.sessionLoadError = undefined;
entry.progressMessage = undefined;
entry.chatState = entry.activePromptAttemptId ? ChatState.Streaming : ChatState.Idle;
return notify(sessionId, entry);
};
@ -208,6 +212,7 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal {
) => {
const entry = getOrCreateEntry(sessionId);
entry.sessionLoadError = sessionLoadError;
entry.progressMessage = undefined;
entry.chatState = ChatState.Idle;
return notify(sessionId, entry);
};
@ -237,6 +242,9 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal {
const setChatState: AcpChatSessionActions['setChatState'] = (sessionId, chatState) => {
const entry = getOrCreateEntry(sessionId);
if (chatState === ChatState.Idle) {
entry.progressMessage = undefined;
}
entry.chatState = chatState;
return notify(sessionId, entry);
};
@ -287,6 +295,7 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal {
entry.chatState = ChatState.Streaming;
entry.sessionLoadError = undefined;
entry.notifications = [];
entry.progressMessage = undefined;
return notify(sessionId, entry);
};
@ -309,6 +318,7 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal {
entry.pendingCancelPromptAttemptId = promptAttemptId;
entry.pendingUserInputRequestIds.clear();
discardPendingLocalSteerMessages(entry);
entry.progressMessage = undefined;
entry.chatState = ChatState.Idle;
return notify(sessionId, entry);
};
@ -385,6 +395,7 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal {
entry.promptCancellationRestoreState = null;
entry.pendingUserInputRequestIds.clear();
discardPendingLocalSteerMessages(entry);
entry.progressMessage = undefined;
entry.chatState = ChatState.Idle;
entry.sessionLoadError = error;
notify(sessionId, entry);
@ -416,6 +427,9 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal {
notification
) => {
const entry = getOrCreateEntry(notification.sessionId);
if (shouldClearProgressMessage(notification)) {
entry.progressMessage = undefined;
}
const changes = entry.adapter.apply(notification);
applyChatStateChanges(entry, changes);
return notify(notification.sessionId, entry);
@ -580,6 +594,9 @@ function applyChatStateChanges(entry: StoreEntry, changes: AcpChatStateChange[])
case 'tokenState':
entry.tokenState = { ...entry.tokenState, ...change.tokenState };
break;
case 'progressMessage':
entry.progressMessage = change.message;
break;
case 'sessionInfo':
if (change.name && entry.session) {
entry.session = { ...entry.session, name: change.name };
@ -598,10 +615,22 @@ function applyChatStateChanges(entry: StoreEntry, changes: AcpChatStateChange[])
}
}
function shouldClearProgressMessage(notification: SessionNotification): boolean {
switch (notification.update.sessionUpdate) {
case 'agent_message_chunk':
case 'agent_thought_chunk':
case 'tool_call':
return true;
default:
return false;
}
}
function resetReplayState(entry: StoreEntry): void {
entry.messages = [];
entry.tokenState = { ...initialTokenState };
entry.notifications = [];
entry.progressMessage = undefined;
entry.activeRunId = null;
entry.pendingCancelPromptAttemptId = null;
entry.promptCancellationRestoreState = null;
@ -675,6 +704,7 @@ function snapshotFromEntry(entry: StoreEntry): AcpChatSessionSnapshot {
messages: cloneMessages(entry.messages),
tokenState: { ...entry.tokenState },
notifications: [...entry.notifications],
progressMessage: entry.progressMessage,
chatState: entry.chatState,
sessionLoadError: entry.sessionLoadError,
activePromptAttemptId: entry.activePromptAttemptId,

View file

@ -56,6 +56,11 @@ export async function deleteLocalModel(modelId: string): Promise<void> {
await client.goose.localInferenceModelsDelete_unstable({ modelId });
}
export async function evictLocalModel(modelId: string): Promise<void> {
const client = await getAcpClient();
await client.goose.localInferenceModelsEvict_unstable({ modelId });
}
export async function getModelSettings(modelId: string): Promise<ModelSettings> {
const client = await getAcpClient();
const response = await client.goose.localInferenceModelsSettingsRead_unstable({ modelId });

View file

@ -23,12 +23,7 @@ import { RecipeWarningModal } from './ui/RecipeWarningModal';
import { scanRecipe } from '../recipe';
import type { Recipe } from '../recipe';
import RecipeActivities from './recipes/RecipeActivities';
import {
getThinkingMessage,
getTextAndImageContent,
type Message,
type UserInput,
} from '../types/message';
import { getTextAndImageContent, type Message, type UserInput } from '../types/message';
import { substituteParameters } from '../utils/parameterSubstitution';
import { useAutoSubmit } from '../hooks/useAutoSubmit';
import { Goose } from './icons';
@ -92,6 +87,7 @@ export default function BaseChat({
session,
messages,
chatState,
progressMessage,
updateSession,
handleSubmit,
onSteerQueuedMessage,
@ -469,14 +465,7 @@ export default function BaseChat({
{chatState !== ChatState.Idle && (
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
<LoadingGoose
chatState={chatState}
message={
messages.length > 0
? getThinkingMessage(messages[messages.length - 1])
: undefined
}
/>
<LoadingGoose chatState={chatState} message={progressMessage} />
</div>
)}
</div>

View file

@ -8,6 +8,8 @@ import {
Settings2,
Eye,
RefreshCw,
Cpu,
PowerOff,
} from 'lucide-react';
import { Button } from '../../ui/button';
import { useModelAndProvider } from '../../ModelAndProviderContext';
@ -18,6 +20,7 @@ import {
getLocalModelDownloadProgress,
cancelLocalModelDownload,
deleteLocalModel,
evictLocalModel,
type DownloadProgress,
type DownloadModelRequest,
type LocalModelResponse,
@ -106,6 +109,14 @@ const i18n = defineMessages({
id: 'localInferenceSettings.modelSettingsTitle',
defaultMessage: 'Model settings',
},
loadedInMemory: {
id: 'localInferenceSettings.loadedInMemory',
defaultMessage: 'Loaded in memory',
},
evictFromMemory: {
id: 'localInferenceSettings.evictFromMemory',
defaultMessage: 'Evict from memory',
},
vision: {
id: 'localInferenceSettings.vision',
defaultMessage: 'Vision',
@ -181,6 +192,7 @@ export const LocalInferenceSettings = () => {
const [downloadRequests, setDownloadRequests] = useState<Map<string, DownloadModelRequest>>(
new Map()
);
const [evictingModelId, setEvictingModelId] = useState<string | null>(null);
const [showAllFeatured, setShowAllFeatured] = useState(false);
const [settingsOpenFor, setSettingsOpenFor] = useState<string | null>(null);
const { currentModel, currentProvider, refreshCurrentModelAndProvider } = useModelAndProvider();
@ -371,6 +383,18 @@ export const LocalInferenceSettings = () => {
}
};
const handleEvictModel = async (modelId: string) => {
setEvictingModelId(modelId);
try {
await evictLocalModel(modelId);
await loadModels();
} catch (error) {
console.error('Failed to evict model:', error);
} finally {
setEvictingModelId(null);
}
};
const handleHfDownloadStarted = (modelId: string, request: DownloadModelRequest) => {
setDownloadRequests((prev) => new Map(prev).set(modelId, request));
pollDownloadProgress(modelId);
@ -393,6 +417,15 @@ export const LocalInferenceSettings = () => {
.map(([modelId]) => modelId)
);
useEffect(() => {
if (downloadedModels.length === 0) return;
const interval = setInterval(() => {
loadModels();
}, 3000);
return () => clearInterval(interval);
}, [downloadedModels.length, loadModels]);
return (
<div className="space-y-6">
<div>
@ -526,18 +559,26 @@ export const LocalInferenceSettings = () => {
: 'border-border-subtle bg-background-default hover:border-border-default'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 flex-wrap">
<input
type="radio"
checked={isSelected}
onChange={() => selectModel(model.id)}
className="cursor-pointer"
/>
<span className="text-sm font-medium text-text-default">{model.id}</span>
<span className="text-sm font-medium text-text-default break-all">
{model.id}
</span>
<span className="text-xs text-text-muted">
{formatBytes(model.sizeBytes)}
</span>
{model.isLoaded && (
<span className="inline-flex items-center gap-1 text-xs text-green-400 bg-green-500/10 px-2 py-0.5 rounded">
<Cpu className="w-3 h-3" />
{intl.formatMessage(i18n.loadedInMemory)}
</span>
)}
{model.recommended && (
<span className="text-xs bg-blue-500 text-white px-2 py-0.5 rounded">
{intl.formatMessage(i18n.recommended)}
@ -545,7 +586,7 @@ export const LocalInferenceSettings = () => {
)}
<VisionBadge model={model} intl={intl} />
</div>
<div className="flex items-center gap-1">
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="sm"
@ -554,6 +595,17 @@ export const LocalInferenceSettings = () => {
>
<Settings2 className="w-4 h-4" />
</Button>
{model.isLoaded && (
<Button
variant="ghost"
size="sm"
onClick={() => handleEvictModel(model.id)}
disabled={evictingModelId === model.id}
title={intl.formatMessage(i18n.evictFromMemory)}
>
<PowerOff className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"

View file

@ -61,6 +61,7 @@ export function useChatSession({
const messages = acpSnapshot?.messages ?? [];
const session = acpSnapshot?.session;
const chatState = acpSnapshot?.chatState ?? ChatState.LoadingConversation;
const progressMessage = acpSnapshot?.progressMessage;
const sessionLoadError = acpSnapshot?.sessionLoadError;
const tokenState = acpSnapshot?.tokenState ?? initialTokenState;
const queueProcessingBlocked = acpSnapshot?.pendingCancelPromptAttemptId != null;
@ -319,6 +320,7 @@ export function useChatSession({
messages,
session,
chatState,
progressMessage,
updateSession,
handleSubmit,
onSteerQueuedMessage,

View file

@ -13,6 +13,7 @@ export interface UseChatSessionResult {
session?: Session;
messages: Message[];
chatState: ChatState;
progressMessage?: string;
updateSession: (updater: (session: Session) => Session) => void;
handleSubmit: (input: UserInput) => Promise<void>;
onSteerQueuedMessage?: (input: UserInput) => Promise<boolean>;

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloading": {
"defaultMessage": "Wird heruntergeladen"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "Empfohlene Modelle"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "Melden Sie sich an, um die Ratenbegrenzungen beim Suchen und Herunterladen von Modellen zu erhöhen und auf private oder zugriffsbeschränkte Hugging-Face-Repositories zuzugreifen."
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "Modelleinstellungen"
},

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloading": {
"defaultMessage": "Downloading"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "Featured Models"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "Sign in to increase rate limits when searching and downloading models, and to access private or gated Hugging Face repositories."
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "Model Settings"
},

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloadProgress": {
"defaultMessage": "{downloaded} / {total} ({percent}%)"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "Modelos destacados"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "Inicia sesión para aumentar los límites de uso al buscar y descargar modelos, y para acceder a repositorios de Hugging Face privados o restringidos."
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "Ajustes del modelo"
},

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloading": {
"defaultMessage": "Téléchargement en cours"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "Modèles en vedette"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "Connectez-vous pour augmenter les limites de débit lors de la recherche et du téléchargement de modèles, et pour accéder aux dépôts Hugging Face privés ou à accès restreint."
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "Paramètres du modèle"
},

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloadProgress": {
"defaultMessage": "{downloaded} / {total} ({percent}%)"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "विशेष रुप से प्रदर्शित मॉडल"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "मॉडल खोजते और डाउनलोड करते समय दर सीमा बढ़ाने के लिए और निजी या गेटेड हगिंग फेस रिपॉजिटरी तक पहुंचने के लिए साइन इन करें।"
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "मॉडल Settings"
},

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloading": {
"defaultMessage": "Mengunduh"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "Model Unggulan"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "Masuk untuk meningkatkan batas laju saat mencari dan mengunduh model, serta untuk mengakses repositori Hugging Face yang privat atau terbatas."
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "Pengaturan Model"
},

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloading": {
"defaultMessage": "Download in corso"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "Modelli in evidenza"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "Accedi per aumentare i limiti di frequenza durante la ricerca e il download dei modelli e per accedere ai repository Hugging Face privati o ad accesso limitato."
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "Impostazioni modello"
},

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloadProgress": {
"defaultMessage": "{downloaded} / {total} ({percent}%)"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "注目モデル"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "モデルの検索・ダウンロード時のレート制限を緩和し、非公開またはゲート付きHugging Faceリポジトリにアクセスするにはサインインしてください。"
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "モデル設定"
},

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloadProgress": {
"defaultMessage": "{downloaded} / {total} ({percent}%)"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "주요 모델"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "모델 검색 및 다운로드 시 속도 제한 한도를 높이고 비공개 또는 접근 제한된 Hugging Face 리포지토리에 액세스하려면 로그인하세요."
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "모델 설정"
},

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloading": {
"defaultMessage": "Memuat turun"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "Model Pilihan"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "Log masuk untuk meningkatkan had kadar semasa mencari dan memuat turun model, dan untuk mengakses repositori Hugging Face peribadi atau berpagar."
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "Tetapan Model"
},

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloading": {
"defaultMessage": "Baixando"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "Modelos em Destaque"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "Faça login para aumentar os limites de taxa ao pesquisar e baixar modelos, e para acessar repositórios privados ou restritos do Hugging Face."
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "Configurações do Modelo"
},

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloadProgress": {
"defaultMessage": "{downloaded} / {total} ({percent}%)"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "Рекомендуемые модели"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "Войдите, чтобы увеличить лимиты запросов при поиске и загрузке моделей, а также получить доступ к приватным или ограниченным репозиториям Hugging Face."
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "Настройки модели"
},

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloadProgress": {
"defaultMessage": "{downloaded} / {total} (%{percent})"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "Öne Çıkan Modeller"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "Model ararken ve indirirken hız sınırlarını artırmak ve özel ya da erişimi kısıtlı Hugging Face depolarına erişmek için oturum açın."
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "Modeli Ayarları"
},

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloading": {
"defaultMessage": "Đang tải xuống"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "Mô hình nổi bật"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "Đăng nhập để tăng giới hạn tốc độ khi tìm kiếm và tải xuống mô hình, và để truy cập các kho lưu trữ Hugging Face riêng tư hoặc bị giới hạn."
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "Cài đặt mô hình"
},

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloadProgress": {
"defaultMessage": "{downloaded} / {total}{percent}%"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "推荐模型"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "登录后可在搜索和下载模型时提高速率限制,并访问私有或受限的 Hugging Face 仓库。"
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "模型设置"
},

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloading": {
"defaultMessage": "下載中"
},
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": {
"defaultMessage": "精選模型"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "登入以在搜尋與下載模型時提高速率上限,並存取私人或受限的 Hugging Face 儲存庫。"
},
"localInferenceSettings.loadedInMemory": {
"defaultMessage": "Loaded in memory"
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "模型設定"
},

View file

@ -93,7 +93,11 @@ type ContentIcon = {
theme?: 'light' | 'dark' | JsonObject;
};
export type SystemNotificationType = 'thinkingMessage' | 'inlineMessage' | 'creditsExhausted';
export type SystemNotificationType =
| 'thinkingMessage'
| 'progressMessage'
| 'inlineMessage'
| 'creditsExhausted';
export type SystemNotificationContent = {
data?: unknown;
@ -443,17 +447,3 @@ export function hasCompletedToolCalls(message: Message): boolean {
const toolRequests = getToolRequests(message);
return toolRequests.length > 0;
}
export function getThinkingMessage(message: Message | undefined): string | undefined {
if (!message || message.role !== 'assistant') {
return undefined;
}
for (const content of message.content) {
if (content.type === 'systemNotification' && content.notificationType === 'thinkingMessage') {
return content.msg;
}
}
return undefined;
}

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { errorMessage } from './conversionUtils';
describe('errorMessage', () => {
it('prefers ACP JSON-RPC error data over generic messages', () => {
expect(
errorMessage({
error: {
message: 'Invalid params',
data: 'MLX backend error: failed to load model',
},
})
).toBe('MLX backend error: failed to load model');
});
it('prefers ACP JSON-RPC error data from Error instances', () => {
const error = Object.assign(new Error('Invalid params'), {
error: {
message: 'Invalid params',
data: 'MLX backend error: failed to load model',
},
});
expect(errorMessage(error)).toBe('MLX backend error: failed to load model');
});
});

View file

@ -13,6 +13,11 @@ export async function safeJsonParse<T>(
}
export function errorMessage(err: Error | unknown, default_value?: string) {
const acpData = acpErrorData(err);
if (typeof acpData === 'string') {
return acpData;
}
if (err instanceof Error) {
return err.message;
} else if (typeof err === 'object' && err !== null && 'message' in err) {
@ -22,6 +27,19 @@ export function errorMessage(err: Error | unknown, default_value?: string) {
}
}
function acpErrorData(err: unknown): unknown {
if (typeof err !== 'object' || err === null) {
return undefined;
}
const candidate = 'error' in err && isRecord(err.error) ? err.error : err;
return isRecord(candidate) ? candidate.data : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function formatErrorForLogging(error: unknown): string {
if (error instanceof Error) {
return `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ''}`;

View file

@ -121,6 +121,7 @@ import type {
LocalInferenceModelDownloadProgressResponse_unstable,
LocalInferenceModelDownloadRequest_unstable,
LocalInferenceModelDownloadResponse_unstable,
LocalInferenceModelEvictRequest_unstable,
LocalInferenceModelSettingsReadRequest_unstable,
LocalInferenceModelSettingsReadResponse_unstable,
LocalInferenceModelSettingsUpdateRequest_unstable,
@ -1377,6 +1378,15 @@ export class GooseExtClient {
);
}
async localInferenceModelsEvict_unstable(
params: LocalInferenceModelEvictRequest_unstable,
): Promise<void> {
await this.conn.extMethod(
"_goose/unstable/local-inference/models/evict",
params,
);
}
async localInferenceModelsSettingsRead_unstable(
params: LocalInferenceModelSettingsReadRequest_unstable,
): Promise<LocalInferenceModelSettingsReadResponse_unstable> {

File diff suppressed because one or more lines are too long

View file

@ -2329,6 +2329,7 @@ export type LocalInferenceModelDto = {
sizeBytes: number;
status: LocalInferenceModelDownloadStatusDto;
recommended: boolean;
isLoaded: boolean;
settings: LocalInferenceModelSettingsDto;
visionCapable: boolean;
mmprojStatus?: LocalInferenceModelDownloadStatusDto | null;
@ -2433,6 +2434,10 @@ export type LocalInferenceModelDeleteRequest_unstable = {
modelId: string;
};
export type LocalInferenceModelEvictRequest_unstable = {
modelId: string;
};
export type LocalInferenceModelSettingsReadRequest_unstable = {
modelId: string;
};
@ -2618,7 +2623,7 @@ export type RecipeParamsAction = 'submit' | 'cancel';
export type ExtRequest = {
id: string;
method: string;
params?: AddSessionExtensionRequest_unstable | RemoveSessionExtensionRequest_unstable | GetToolsRequest_unstable | SetToolPermissionsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | AppsListRequest_unstable | AppsExportRequest_unstable | AppsImportRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DiagnosticsGetRequest_unstable | ListPromptsRequest_unstable | GetPromptRequest_unstable | SavePromptRequest_unstable | ResetPromptRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | ProviderSecretsListRequest_unstable | ProviderSecretDeleteRequest_unstable | CanonicalModelInfoRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | ConfigReadRequest_unstable | ConfigUpsertRequest_unstable | ConfigRemoveRequest_unstable | ConfigReadAllRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | DefaultsClearRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | ShareSessionNostrRequest_unstable | EncodeRecipeRequest_unstable | DecodeRecipeRequest_unstable | ScanRecipeRequest_unstable | ListRecipesRequest_unstable | DeleteRecipeRequest_unstable | ScheduleRecipeRequest_unstable | SetRecipeSlashCommandRequest_unstable | SaveRecipeRequest_unstable | ParseRecipeRequest_unstable | RecipeToYamlRequest_unstable | ListSchedulesRequest_unstable | ListScheduleSessionsRequest_unstable | CreateScheduleRequest_unstable | DeleteScheduleRequest_unstable | PauseScheduleRequest_unstable | UnpauseScheduleRequest_unstable | UpdateScheduleRequest_unstable | RunScheduleNowRequest_unstable | KillRunningJobRequest_unstable | InspectRunningJobRequest_unstable | GetSessionInfoRequest_unstable | TruncateSessionConversationRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | ListAgentMentionsRequest_unstable | ListSlashCommandsRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | LocalInferenceModelsListRequest_unstable | LocalInferenceModelDownloadRequest_unstable | LocalInferenceModelDownloadProgressRequest_unstable | LocalInferenceModelDownloadCancelRequest_unstable | LocalInferenceModelDeleteRequest_unstable | LocalInferenceModelSettingsReadRequest_unstable | LocalInferenceModelSettingsUpdateRequest_unstable | LocalInferenceHuggingFaceSearchRequest_unstable | LocalInferenceHuggingFaceRepoVariantsRequest_unstable | LocalInferenceBuiltinChatTemplatesListRequest_unstable | {
params?: AddSessionExtensionRequest_unstable | RemoveSessionExtensionRequest_unstable | GetToolsRequest_unstable | SetToolPermissionsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | AppsListRequest_unstable | AppsExportRequest_unstable | AppsImportRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DiagnosticsGetRequest_unstable | ListPromptsRequest_unstable | GetPromptRequest_unstable | SavePromptRequest_unstable | ResetPromptRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | ProviderSecretsListRequest_unstable | ProviderSecretDeleteRequest_unstable | CanonicalModelInfoRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | ConfigReadRequest_unstable | ConfigUpsertRequest_unstable | ConfigRemoveRequest_unstable | ConfigReadAllRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | DefaultsClearRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | ShareSessionNostrRequest_unstable | EncodeRecipeRequest_unstable | DecodeRecipeRequest_unstable | ScanRecipeRequest_unstable | ListRecipesRequest_unstable | DeleteRecipeRequest_unstable | ScheduleRecipeRequest_unstable | SetRecipeSlashCommandRequest_unstable | SaveRecipeRequest_unstable | ParseRecipeRequest_unstable | RecipeToYamlRequest_unstable | ListSchedulesRequest_unstable | ListScheduleSessionsRequest_unstable | CreateScheduleRequest_unstable | DeleteScheduleRequest_unstable | PauseScheduleRequest_unstable | UnpauseScheduleRequest_unstable | UpdateScheduleRequest_unstable | RunScheduleNowRequest_unstable | KillRunningJobRequest_unstable | InspectRunningJobRequest_unstable | GetSessionInfoRequest_unstable | TruncateSessionConversationRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | ListAgentMentionsRequest_unstable | ListSlashCommandsRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | LocalInferenceModelsListRequest_unstable | LocalInferenceModelDownloadRequest_unstable | LocalInferenceModelDownloadProgressRequest_unstable | LocalInferenceModelDownloadCancelRequest_unstable | LocalInferenceModelDeleteRequest_unstable | LocalInferenceModelEvictRequest_unstable | LocalInferenceModelSettingsReadRequest_unstable | LocalInferenceModelSettingsUpdateRequest_unstable | LocalInferenceHuggingFaceSearchRequest_unstable | LocalInferenceHuggingFaceRepoVariantsRequest_unstable | LocalInferenceBuiltinChatTemplatesListRequest_unstable | {
[key: string]: unknown;
} | null;
};

View file

@ -2505,6 +2505,7 @@ export const zLocalInferenceModelDto = z.object({
sizeBytes: z.number().int().gte(0),
status: zLocalInferenceModelDownloadStatusDto,
recommended: z.boolean(),
isLoaded: z.boolean(),
settings: zLocalInferenceModelSettingsDto,
visionCapable: z.boolean(),
mmprojStatus: z.union([
@ -2573,6 +2574,10 @@ export const zLocalInferenceModelDeleteRequest_unstable = z.object({
modelId: z.string()
});
export const zLocalInferenceModelEvictRequest_unstable = z.object({
modelId: z.string()
});
export const zLocalInferenceModelSettingsReadRequest_unstable = z.object({
modelId: z.string()
});
@ -2914,6 +2919,7 @@ export const zExtRequest = z.object({
zLocalInferenceModelDownloadProgressRequest_unstable,
zLocalInferenceModelDownloadCancelRequest_unstable,
zLocalInferenceModelDeleteRequest_unstable,
zLocalInferenceModelEvictRequest_unstable,
zLocalInferenceModelSettingsReadRequest_unstable,
zLocalInferenceModelSettingsUpdateRequest_unstable,
zLocalInferenceHuggingFaceSearchRequest_unstable,