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", "bincode",
"bytesize", "bytesize",
"clircle", "clircle",
"console 0.16.3", "console",
"content_inspector", "content_inspector",
"encoding_rs", "encoding_rs",
"flate2", "flate2",
@ -1255,26 +1255,6 @@ dependencies = [
"serde", "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]] [[package]]
name = "bindgen" name = "bindgen"
version = "0.72.1" version = "0.72.1"
@ -2302,8 +2282,8 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4529f45438fc25ca048b242d5c48e2d3ce9a521e2a5a9123d9737d8520b030dd" checksum = "4529f45438fc25ca048b242d5c48e2d3ce9a521e2a5a9123d9737d8520b030dd"
dependencies = [ dependencies = [
"console 0.16.3", "console",
"indicatif 0.18.4", "indicatif",
"once_cell", "once_cell",
"strsim", "strsim",
"textwrap", "textwrap",
@ -2478,19 +2458,6 @@ dependencies = [
"winnow 1.0.3", "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]] [[package]]
name = "console" name = "console"
version = "0.16.3" version = "0.16.3"
@ -5024,7 +4991,7 @@ dependencies = [
"clap_mangen", "clap_mangen",
"cliclack", "cliclack",
"comfy-table", "comfy-table",
"console 0.16.3", "console",
"dotenvy", "dotenvy",
"env-lock", "env-lock",
"etcetera 0.11.0", "etcetera 0.11.0",
@ -5032,7 +4999,7 @@ dependencies = [
"goose", "goose",
"goose-mcp", "goose-mcp",
"goose-providers", "goose-providers",
"indicatif 0.18.4", "indicatif",
"open", "open",
"rand 0.10.1", "rand 0.10.1",
"regex", "regex",
@ -5089,17 +5056,17 @@ dependencies = [
"goose-download-manager", "goose-download-manager",
"goose-provider-types", "goose-provider-types",
"goose-sdk-types", "goose-sdk-types",
"hf-hub 1.0.0-rc.1", "hf-hub",
"include_dir", "include_dir",
"llama-cpp-2", "llama-cpp-2",
"llama-cpp-sys-2", "llama-cpp-sys-2",
"minijinja", "minijinja",
"mlx-lm",
"mlx-lm-utils",
"mlx-rs",
"regex", "regex",
"reqwest 0.13.4", "reqwest 0.13.4",
"rmcp", "rmcp",
"safemlx",
"safemlx-lm",
"safemlx-lm-utils",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
@ -5447,25 +5414,6 @@ dependencies = [
"arrayvec", "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]] [[package]]
name = "hf-hub" name = "hf-hub"
version = "1.0.0-rc.1" version = "1.0.0-rc.1"
@ -6070,26 +6018,13 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "indicatif" name = "indicatif"
version = "0.18.4" version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb"
dependencies = [ dependencies = [
"console 0.16.3", "console",
"portable-atomic", "portable-atomic",
"unicode-width 0.2.2", "unicode-width 0.2.2",
"unit-prefix", "unit-prefix",
@ -6611,7 +6546,7 @@ version = "0.1.146"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b291e4bc2d10c43cd8dec16d49b6104cb3cb125f596ec380a753a5db1d965dd" checksum = "9b291e4bc2d10c43cd8dec16d49b6104cb3cb125f596ec380a753a5db1d965dd"
dependencies = [ dependencies = [
"bindgen 0.72.1", "bindgen",
"cc", "cc",
"cmake", "cmake",
"find_cuda_helper", "find_cuda_helper",
@ -6862,99 +6797,6 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "mockall" name = "mockall"
version = "0.15.0" version = "0.15.0"
@ -7398,19 +7240,13 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "oauth2" name = "oauth2"
version = "5.0.0" version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [ dependencies = [
"base64 0.21.7", "base64 0.22.1",
"chrono", "chrono",
"getrandom 0.2.17", "getrandom 0.2.17",
"http 1.4.2", "http 1.4.2",
@ -8564,7 +8400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"itertools 0.13.0", "itertools 0.14.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.117", "syn 2.0.117",
@ -9347,7 +9183,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"log",
"once_cell", "once_cell",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@ -9462,6 +9297,99 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3944826ff8fa8093089aba3acb4ef44b9446a99a16f3bf4e74af3f77d340ab7d" 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]] [[package]]
name = "safetensors" name = "safetensors"
version = "0.4.5" version = "0.4.5"
@ -10317,17 +10245,6 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "sourcemap" name = "sourcemap"
version = "9.3.2" version = "9.3.2"
@ -11777,8 +11694,6 @@ dependencies = [
"derive_builder", "derive_builder",
"esaxx-rs", "esaxx-rs",
"getrandom 0.3.4", "getrandom 0.3.4",
"hf-hub 0.4.3",
"indicatif 0.18.4",
"itertools 0.14.0", "itertools 0.14.0",
"log", "log",
"macro_rules_attribute", "macro_rules_attribute",
@ -12810,25 +12725,6 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 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]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@ -12948,7 +12844,7 @@ version = "145.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f84468f393984251db025e944544590a8fb429d5088b78102bd72f09232f0da" checksum = "6f84468f393984251db025e944544590a8fb429d5088b78102bd72f09232f0da"
dependencies = [ dependencies = [
"bindgen 0.72.1", "bindgen",
"bitflags 2.11.1", "bitflags 2.11.1",
"fslock", "fslock",
"gzip-header", "gzip-header",

View file

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

View file

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

View file

@ -15,7 +15,7 @@ workspace = true
default = [] default = []
cuda = ["llama-cpp-2/cuda"] cuda = ["llama-cpp-2/cuda"]
vulkan = ["llama-cpp-2/vulkan"] 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] [dependencies]
anyhow = { workspace = true } anyhow = { workspace = true }
@ -46,9 +46,9 @@ utoipa = { workspace = true, features = ["chrono"] }
uuid = { workspace = true, features = ["v4", "std"] } uuid = { workspace = true, features = ["v4", "std"] }
tempfile = { workspace = true } tempfile = { workspace = true }
mlx-rs = { version = "0.25.3", default-features = false, features = ["accelerate", "metal", "safetensors"], optional = true } safemlx = { default-features = false, features = ["accelerate", "metal", "safetensors"], optional = true, version = "0.1.2" }
mlx-lm = { git = "https://github.com/jh-block/mlx-lm", optional = true } safemlx-lm = { optional = true, version = "0.1.5" }
mlx-lm-utils = { git = "https://github.com/jh-block/mlx-lm", optional = true } safemlx-lm-utils = { optional = true, version = "0.1.2" }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
llama-cpp-2 = { workspace = true, features = ["sampler", "metal", "mtmd"] } 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 temperature: Option<f32>,
pub max_tokens: Option<i32>, pub max_tokens: Option<i32>,
pub context_limit: usize, pub context_limit: usize,
pub model_load_ms: Option<u64>,
pub resolved_model: &'a ResolvedModelPaths, pub resolved_model: &'a ResolvedModelPaths,
pub draft_model_path: Option<std::path::PathBuf>, pub draft_model_path: Option<std::path::PathBuf>,
pub message_id: &'a str, pub message_id: &'a str,

View file

@ -1085,20 +1085,13 @@ mod tests {
} }
#[test] #[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" })); let config = Some(serde_json::json!({ "model_type": "llama" }));
for tokenizer_files in [ assert!(is_mlx_compatible_repo(
vec!["tokenizer.json"], &config,
vec!["tokenizer.model"], &mlx_siblings(&["tokenizer.json"])
vec!["tokenizer.tiktoken"], ));
vec!["vocab.json", "merges.txt"],
] {
assert!(
is_mlx_compatible_repo(&config, &mlx_siblings(&tokenizer_files)),
"{tokenizer_files:?}"
);
}
} }
#[test] #[test]
@ -1107,8 +1100,11 @@ mod tests {
for tokenizer_files in [ for tokenizer_files in [
vec!["tokenizer_config.json"], vec!["tokenizer_config.json"],
vec!["tokenizer.model"],
vec!["tokenizer.tiktoken"],
vec!["vocab.json"], vec!["vocab.json"],
vec!["merges.txt"], vec!["merges.txt"],
vec!["vocab.json", "merges.txt"],
] { ] {
assert!( assert!(
!is_mlx_compatible_repo(&config, &mlx_siblings(&tokenizer_files)), !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 { fn test_model(repo_id: &str) -> HfModelInfo {
HfModelInfo { HfModelInfo {
repo_id: repo_id.to_string(), repo_id: repo_id.to_string(),
@ -1157,6 +1222,13 @@ mod tests {
assert_eq!(results[0].repo_id, "gguf/repo"); 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] #[test]
fn test_recommend_variant() { fn test_recommend_variant() {
let variants = vec![ 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 { if results.len() >= limit {
break; 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); 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( async fn get_local_model_info_for_repo_with_client(
client: &HFClient, client: &HFClient,
repo_id: &str, 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>> { ) -> Result<Option<HfModelInfo>> {
let repo = model_repo(client, repo_id)?; let repo = model_repo(client, repo_id)?;
let info = repo let info = repo
@ -1495,7 +1581,7 @@ async fn get_local_model_info_for_repo_with_client(
]) ])
.send() .send()
.await?; .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>> { 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) 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 repo_id = info.id.clone();
let mut variants: Vec<HfModelVariant> = get_repo_gguf_variants(&repo_id) let mut variants: Vec<HfModelVariant> = get_repo_gguf_variants(&repo_id)
.await .await
@ -1516,7 +1606,13 @@ async fn model_info_to_local_model_info(info: ModelInfo) -> Result<Option<HfMode
.iter() .iter()
.map(|variant| variant.to_model_variant(&repo_id)) .map(|variant| variant.to_model_variant(&repo_id))
.collect(); .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() { if variants.is_empty() {
return Ok(None); return Ok(None);
@ -1530,17 +1626,49 @@ async fn model_info_to_local_model_info(info: ModelInfo) -> Result<Option<HfMode
.next_back() .next_back()
.unwrap_or(&repo_id) .unwrap_or(&repo_id)
.to_string(); .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 { Ok(Some(HfModelInfo {
repo_id, repo_id,
author, author,
model_name, model_name,
downloads: info.downloads.unwrap_or(0), downloads,
gguf_files: Vec::new(), gguf_files: Vec::new(),
variants, 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>> { pub async fn get_repo_local_variants(repo_id: &str) -> Result<Vec<HfModelVariant>> {
let mut variants: Vec<HfModelVariant> = get_repo_gguf_variants(repo_id) let mut variants: Vec<HfModelVariant> = get_repo_gguf_variants(repo_id)
.await .await
@ -1570,25 +1698,39 @@ pub async fn get_repo_mlx_variants(repo_id: &str) -> Result<Vec<HfModelVariant>>
]) ])
.send() .send()
.await?; .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(&[]); let siblings = info.siblings.as_deref().unwrap_or(&[]);
if !is_mlx_compatible_repo(&info.config, siblings) { if !is_mlx_compatible_repo(&info.config, siblings) {
return Vec::new(); return Vec::new();
} }
let size_bytes = mlx_download_filenames(siblings) let size_bytes = mlx_download_size_bytes(info, siblings);
.into_iter()
.filter_map(|filename| {
siblings
.iter()
.find(|s| s.rfilename == filename)
.and_then(|s| s.size)
})
.sum();
let variant_id = mlx_variant_id(repo_id, &info.config); let variant_id = mlx_variant_id(repo_id, &info.config);
vec![HfModelVariant { vec![HfModelVariant {
@ -1601,20 +1743,24 @@ fn mlx_variants_from_model_info(repo_id: &str, info: &ModelInfo) -> Vec<HfModelV
size_bytes, size_bytes,
filename: None, filename: None,
download_url: None, download_url: None,
description: mlx_variant_description(&info.config), description: mlx_variant_description(mlx_config),
quality_rank: 91, quality_rank: 91,
sharded: siblings sharded: siblings
.iter() .iter()
.filter(|s| s.rfilename.ends_with(".safetensors")) .filter(|s| s.rfilename.ends_with(".safetensors"))
.count() .count()
> 1, > 1,
supported: is_mlx_runtime_supported(&info.config) supported: is_mlx_runtime_supported(mlx_config)
&& cfg!(target_os = "macos") && cfg!(target_os = "macos")
&& cfg!(feature = "mlx"), && 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 { 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_config = siblings.iter().any(|s| s.rfilename == "config.json");
let has_tokenizer = has_mlx_tokenizer(siblings); 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() 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 { fn has_mlx_tokenizer(siblings: &[RepoSibling]) -> bool {
let has_file = |filename: &str| siblings.iter().any(|s| s.rfilename == filename);
siblings siblings
.iter() .iter()
.any(|s| is_standalone_mlx_tokenizer_file(&s.rfilename)) .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 { 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> { 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()) .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 { fn is_mlx_runtime_supported(config: &Option<serde_json::Value>) -> bool {
mlx_model_type(config).is_some_and(is_mlx_runtime_supported_model_type) mlx_config_support(config).is_none()
&& !is_mlx_moe_model(config)
} }
fn mlx_unsupported_reason(config: &Option<serde_json::Value>) -> Option<String> { 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()); return Some("MLX support was not compiled in".to_string());
} }
let model_type = mlx_model_type(config)?; mlx_config_support(config)
if !is_mlx_runtime_supported_model_type(model_type) { }
return Some(format!(
"MLX backend does not support '{}' models yet", fn mlx_config_support(config: &Option<serde_json::Value>) -> Option<String> {
model_type let config = config.as_ref()?;
)); mlx_config_support_for_value(config)
} }
if is_mlx_moe_model(config) {
return Some("MLX backend does not support Gemma 4 MoE models yet".to_string()); #[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 None
} }
@ -1771,6 +1944,10 @@ fn should_download_for_mlx(filename: &str) -> bool {
|| is_standalone_mlx_tokenizer_file(filename) || is_standalone_mlx_tokenizer_file(filename)
|| filename == "tokenizer_config.json" || filename == "tokenizer_config.json"
|| filename == "generation_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 == "special_tokens_map.json"
|| filename == "model.safetensors.index.json" || filename == "model.safetensors.index.json"
|| filename == "vocab.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 { fn mlx_variant_id(repo_id: &str, config: &Option<serde_json::Value>) -> String {
let repo_lower = repo_id.to_lowercase(); 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) { if repo_lower.contains(marker) {
return marker.to_string(); 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 repo = client.model(owner.to_string(), name.to_string());
let info = repo let info = repo
.info() .info()
.expand(vec!["siblings".to_string()]) .expand(vec!["siblings".to_string(), "safetensors".to_string()])
.send() .send()
.await?; .await?;
let siblings = info.siblings.as_deref().unwrap_or(&[]); let siblings = info.siblings.as_deref().unwrap_or(&[]);
let filenames = mlx_download_filenames(siblings); let filenames = mlx_download_filenames(siblings);
let total_size = filenames let total_size = mlx_download_size_bytes(&info, siblings);
.iter()
.filter_map(|filename| {
siblings
.iter()
.find(|s| s.rfilename == *filename)
.and_then(|s| s.size)
})
.sum();
let progress = HfDownloadProgress::new(repo_id.to_string(), total_size); let progress = HfDownloadProgress::new(repo_id.to_string(), total_size);
progress.init(); progress.init();
let mut snapshot_path = None; let mut snapshot_path = None;
@ -2103,8 +2272,13 @@ impl HfDownloadProgress {
} }
fn finish_file(&self, size_bytes: u64) { 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() { 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() { if let Ok(mut state) = self.state.lock() {
state.bytes_downloaded = 0; state.bytes_downloaded = 0;
@ -2117,10 +2291,15 @@ impl HfDownloadProgress {
let completed_bytes = self.completed_bytes.lock().map(|value| *value).unwrap_or(0); let completed_bytes = self.completed_bytes.lock().map(|value| *value).unwrap_or(0);
if let Ok(state) = self.state.lock() { if let Ok(state) = self.state.lock() {
let bytes_downloaded = completed_bytes.saturating_add(state.bytes_downloaded); 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( update_download_manager_progress(
&self.model_id, &self.model_id,
bytes_downloaded.min(self.total_bytes), bytes_downloaded.min(total_bytes),
self.total_bytes.max(state.current_file_total_bytes), total_bytes,
state.speed_bps, state.speed_bps,
); );
} }
@ -2152,10 +2331,12 @@ impl ProgressHandler for HfDownloadProgress {
file.bytes_completed file.bytes_completed
} }
}) })
.max() .sum();
.unwrap_or(0); let total_bytes = files.iter().map(|file| file.total_bytes).sum();
if let Ok(mut state) = self.state.lock() { if let Ok(mut state) = self.state.lock() {
state.bytes_downloaded = state.bytes_downloaded.max(bytes_downloaded); 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(); self.update_progress_from_state();
} }
@ -2168,7 +2349,7 @@ impl ProgressHandler for HfDownloadProgress {
return; return;
} }
if let Ok(mut state) = self.state.lock() { 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 = state.current_file_total_bytes =
(*total_bytes).max(state.current_file_total_bytes); (*total_bytes).max(state.current_file_total_bytes);
state.speed_bps = bytes_per_sec.map(|speed| speed as u64); state.speed_bps = bytes_per_sec.map(|speed| speed as u64);

View file

@ -14,6 +14,7 @@ mod mlx;
pub(crate) mod multimodal; pub(crate) mod multimodal;
#[cfg(feature = "mlx")] #[cfg(feature = "mlx")]
mod native_tool_parsing; mod native_tool_parsing;
pub(crate) mod thinking_output;
#[cfg(feature = "mlx")] #[cfg(feature = "mlx")]
mod tool_emulation; mod tool_emulation;
mod tool_parsing; mod tool_parsing;
@ -23,7 +24,9 @@ use async_stream::try_stream;
use async_trait::async_trait; use async_trait::async_trait;
use backend::{BackendLoadedModel, LocalInferenceBackend}; use backend::{BackendLoadedModel, LocalInferenceBackend};
use goose_provider_types::base::{MessageStream, Provider, ProviderDescriptor, ProviderMetadata}; 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::conversation::token_usage::{ProviderUsage, Usage};
use goose_provider_types::errors::ProviderError; use goose_provider_types::errors::ProviderError;
use goose_provider_types::images::ImageFormat; use goose_provider_types::images::ImageFormat;
@ -34,13 +37,33 @@ use local_model_registry::ChatTemplate;
use mlx::{MlxBackend, MLX_BACKEND_ID}; use mlx::{MlxBackend, MLX_BACKEND_ID};
use rmcp::model::Tool; use rmcp::model::Tool;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex, Weak}; use std::sync::{Arc, Mutex as StdMutex};
use tokio::sync::Mutex; use tokio::sync::{Mutex, Notify};
use uuid::Uuid; 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)] #[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct ModelCacheKey { struct ModelCacheKey {
@ -64,7 +87,8 @@ impl ModelCacheKey {
} }
pub struct InferenceRuntime { pub struct InferenceRuntime {
models: StdMutex<HashMap<ModelCacheKey, ModelSlot>>, models: StdMutex<HashMap<ModelCacheKey, ModelSlotHandle>>,
cold_load_lock: Mutex<()>,
backends: HashMap<&'static str, Arc<dyn LocalInferenceBackend>>, backends: HashMap<&'static str, Arc<dyn LocalInferenceBackend>>,
} }
@ -72,19 +96,17 @@ pub fn builtin_chat_template_names() -> Vec<String> {
llamacpp::builtin_chat_template_names() llamacpp::builtin_chat_template_names()
} }
/// Global weak reference used to share a single `InferenceRuntime` across static RUNTIME: StdMutex<Option<Arc<InferenceRuntime>>> = StdMutex::new(None);
/// 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 fn current_runtime() -> Option<Arc<InferenceRuntime>> {
/// strong refs drop (normal shutdown), the runtime is deallocated and the RUNTIME.lock().expect("runtime lock poisoned").clone()
/// 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());
impl InferenceRuntime { impl InferenceRuntime {
pub fn get_or_init() -> Result<Arc<Self>> { pub fn get_or_init() -> Result<Arc<Self>> {
let mut guard = RUNTIME.lock().expect("runtime lock poisoned"); let mut guard = RUNTIME.lock().expect("runtime lock poisoned");
if let Some(runtime) = guard.upgrade() { if let Some(runtime) = guard.as_ref() {
return Ok(runtime); return Ok(runtime.clone());
} }
let llamacpp_backend: Arc<dyn LocalInferenceBackend> = Arc::new(LlamaCppBackend::new()?); let llamacpp_backend: Arc<dyn LocalInferenceBackend> = Arc::new(LlamaCppBackend::new()?);
let mlx_backend: Arc<dyn LocalInferenceBackend> = Arc::new(MlxBackend::new()); let mlx_backend: Arc<dyn LocalInferenceBackend> = Arc::new(MlxBackend::new());
@ -93,9 +115,10 @@ impl InferenceRuntime {
backends.insert(MLX_BACKEND_ID, mlx_backend); backends.insert(MLX_BACKEND_ID, mlx_backend);
let runtime = Arc::new(Self { let runtime = Arc::new(Self {
models: StdMutex::new(HashMap::new()), models: StdMutex::new(HashMap::new()),
cold_load_lock: Mutex::new(()),
backends, backends,
}); });
*guard = Arc::downgrade(&runtime); *guard = Some(runtime.clone());
Ok(runtime) 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"); let mut map = self.models.lock().expect("model cache lock poisoned");
map.entry(key) map.entry(key)
.or_insert_with(|| Arc::new(Mutex::new(None))) .or_insert_with(|| Arc::new(ModelSlot::new()))
.clone() .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"); let map = self.models.lock().expect("model cache lock poisoned");
map.iter() map.iter()
.filter(|(key, _)| *key != keep_key) .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 PROVIDER_NAME: &str = "local";
const DEFAULT_MODEL: &str = "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M"; 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 backend = self.runtime.backend_for_model(&resolved)?;
let model_context_limit = resolved.context_limit; 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 // 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 if let Some(false) = model_config
.request_param::<bool>("enable_thinking") .request_param::<bool>("enable_thinking")
.or_else(|| { .or_else(|| {
@ -611,6 +677,15 @@ impl Provider for LocalInferenceProvider {
model_settings.enable_thinking = false; 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 model_arc = model_slot.clone();
let backend = backend.clone(); let backend = backend.clone();
let model_name = model_config.model_name.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::< let (tx, mut rx) = tokio::sync::mpsc::channel::<
Result<(Option<Message>, Option<ProviderUsage>), ProviderError>, Result<(Option<Message>, Option<ProviderUsage>), ProviderError>,
>(32); >(32);
let mut log = start_log(model_config, &log_payload)?;
tokio::task::spawn_blocking(move || { tokio::spawn(async move {
// Macro to log errors before sending them through the channel let mut model_load_ms = None;
macro_rules! send_err {
($err:expr) => {{ // Ensure model is loaded — unload any other models first to free memory.
let err = $err; 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 { let msg = match &err {
ProviderError::ExecutionError(s) => s.as_str(), ProviderError::ExecutionError(s) => s.as_str(),
ProviderError::ContextLengthExceeded(s) => s.as_str(), ProviderError::ContextLengthExceeded(s) => s.as_str(),
@ -657,49 +887,8 @@ impl Provider for LocalInferenceProvider {
}; };
let _ = log.error(msg); let _ = log.error(msg);
let _ = tx.blocking_send(Err(err)); 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! { Ok(Box::pin(try_stream! {

View file

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

View file

@ -3,7 +3,6 @@ use crate::local_model_registry::ModelSettings;
use crate::multimodal::ExtractedImage; use crate::multimodal::ExtractedImage;
use goose_provider_types::errors::ProviderError; use goose_provider_types::errors::ProviderError;
use goose_provider_types::request_log::{LoggerHandleExt, RequestLogHandle}; 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::context::params::LlamaContextParams;
use llama_cpp_2::llama_batch::LlamaBatch; use llama_cpp_2::llama_batch::LlamaBatch;
use llama_cpp_2::model::{AddBos, ChatTemplateResult, LlamaChatTemplate, LlamaModel}; use llama_cpp_2::model::{AddBos, ChatTemplateResult, LlamaChatTemplate, LlamaModel};
@ -48,86 +47,6 @@ pub(super) struct PreparedGeneration<'model> {
pub effective_ctx: usize, pub effective_ctx: usize,
} }
pub(super) struct ThinkingOutputFilter {
enabled: bool,
saw_structured_reasoning: bool,
think_filter: ThinkFilter,
pending_inline_thinking: String,
accumulated_thinking: String,
}
impl ThinkingOutputFilter {
pub(super) fn new(enable_thinking: bool, generation_prompt: &str) -> Self {
let mut think_filter = ThinkFilter::new();
if enable_thinking && !generation_prompt.is_empty() {
let _ = think_filter.push(generation_prompt);
}
Self {
enabled: enable_thinking,
saw_structured_reasoning: false,
think_filter,
pending_inline_thinking: String::new(),
accumulated_thinking: String::new(),
}
}
pub(super) fn push_structured_reasoning(&mut self, reasoning: &str) -> Option<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 { pub(super) struct StopSuffixTrimmer {
pending: String, pending: String,
stops: Vec<String>, stops: Vec<String>,

View file

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

View file

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

View file

@ -3,23 +3,24 @@ mod imp {
use std::any::Any; use std::any::Any;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use mlx_lm::cache::ConcatKeyValueCache; use safemlx::transforms::eval;
use mlx_lm::gemma4_mtp::generate_gemma4_mtp; use safemlx::{random, Array, Device, DeviceType, Stream};
use mlx_lm::models::{gemma4_assistant::load_gemma4_assistant_model, LoadedModel, Model}; use safemlx_lm::gemma4_mtp::generate_gemma4_mtp;
use mlx_lm_utils::tokenizer::{Chat, Conversation, Role}; use safemlx_lm::models::{gemma4_assistant::load_gemma4_assistant_model, LoadedModel, Model};
use mlx_rs::transforms::eval; use safemlx_lm_utils::tokenizer::{Chat, Conversation, Role, Tokenizer};
use serde_json::json; use serde_json::json;
use crate::backend::{BackendLoadedModel, LocalGenerationRequest, LocalInferenceBackend}; use crate::backend::{BackendLoadedModel, LocalGenerationRequest, LocalInferenceBackend};
use crate::local_model_registry::{ModelSettings, ToolCallingMode}; use crate::local_model_registry::{ModelSettings, ToolCallingMode};
use crate::native_tool_parsing::message_from_native_tool_text; use crate::native_tool_parsing::message_from_native_tool_text;
use crate::provider_utils::filter_extensions_from_system_prompt; use crate::provider_utils::filter_extensions_from_system_prompt;
use crate::thinking_output::ThinkingOutputFilter;
use crate::tool_emulation::{ use crate::tool_emulation::{
build_emulator_tool_description, load_tiny_model_prompt, message_for_emulator_action, build_emulator_tool_description, load_tiny_model_prompt, message_for_emulator_action,
StreamingEmulatorParser, CODE_EXECUTION_TOOL, StreamingEmulatorParser, CODE_EXECUTION_TOOL,
}; };
use crate::{extract_text_content, ResolvedModelPaths}; 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::{ use goose_provider_types::conversation::token_usage::{
DraftStats, ProviderStats, ProviderUsage, Usage, DraftStats, ProviderStats, ProviderUsage, Usage,
}; };
@ -57,14 +58,25 @@ mod imp {
} }
let model_dir = model_dir_from_path(&resolved.model_path)?; 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!( tracing::info!(
backend = self.id(), backend = self.id(),
model_id, model_id,
model_type = model.model_type(), model_type = model.model_type(),
"MLX model loaded successfully" "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( fn generate(
@ -79,6 +91,7 @@ mod imp {
ProviderError::ExecutionError("Loaded model backend mismatch".to_string()) 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() { let tool_mode = if request.tools.is_empty() {
ToolMode::None ToolMode::None
} else { } else {
@ -110,77 +123,114 @@ mod imp {
let prompt_array = loaded let prompt_array = loaded
.model .model
.encode_to_array(&prompt, false) .encode_to_array(&prompt, false, &stream)
.map_err(mlx_error)?; .map_err(mlx_error)?;
let max_tokens = request let max_tokens = mlx_max_tokens(
.settings request.settings,
.max_output_tokens request.max_tokens,
.or_else(|| { request.context_limit,
request prompt_tokens.len(),
.max_tokens );
.and_then(|tokens| usize::try_from(tokens).ok()) let (settings_temp, seed) = sampling(request.settings);
}) let temp = request.temperature.unwrap_or(settings_temp);
.unwrap_or(512); let prng_key = prng_key(temp, seed)?;
let temp = request let eos_token_ids = loaded.stop_token_ids.clone();
.temperature
.unwrap_or_else(|| temperature(request.settings));
let eos_token_ids = loaded.model.eos_token_ids().to_vec();
let generation_started = std::time::Instant::now(); let generation_started = std::time::Instant::now();
let (generated_ids, draft_stats) = let MlxGeneration {
if let Some(draft_model_path) = &request.draft_model_path { generated_ids,
if matches!(loaded.model.model_mut(), Model::Gemma4(_)) { generated_text,
let mut assistant = draft_stats,
load_gemma4_assistant_model(draft_model_path).map_err(|error| { 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}")) mlx_error(format!("failed to load MLX draft model: {error}"))
})?; })?;
let target = match loaded.model.model_mut() { let target = match loaded.model.model_mut() {
Model::Gemma4(target) => target, Model::Gemma4(target) => target,
_ => unreachable!(), _ => unreachable!(),
}; };
let (ids, stats) = generate_gemma4_mtp( let (ids, stats) = generate_gemma4_mtp(
target, target,
&mut assistant, &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,
&prompt_array, &prompt_array,
&eos_token_ids, &eos_token_ids,
max_tokens, max_tokens,
temp, 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 if !streamed_response {
.model emit_generated_response(
.decode(&generated_ids, true) &generated_text,
.map_err(mlx_error)?; &prompt,
emit_generated_response(&generated_text, request.message_id, tool_mode, request.tx)?; request.settings.enable_thinking,
request.message_id,
tool_mode,
request.tx,
)?;
}
let output_tokens = generated_ids.len() as i32; let output_tokens = generated_ids.len() as i32;
let input_tokens = prompt_tokens.len() as i32; let input_tokens = prompt_tokens.len() as i32;
@ -194,12 +244,16 @@ mod imp {
"model_dir": loaded.model_dir, "model_dir": loaded.model_dir,
"prompt_tokens": input_tokens, "prompt_tokens": input_tokens,
"output_tokens": output_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, "generated_text": generated_text,
"draft": draft_stats, "draft": draft_stats,
}); });
let _ = request.log.write(&log_json, Some(&usage)); let _ = request.log.write(&log_json, Some(&usage));
let stats = ProviderStats { 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), elapsed_ms: Some(generation_started.elapsed().as_millis() as u64),
output_tokens: Some(generated_ids.len()), output_tokens: Some(generated_ids.len()),
draft: draft_stats, draft: draft_stats,
@ -221,9 +275,19 @@ mod imp {
Emulated { code_mode_enabled: bool }, 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 { struct MlxLoadedModel {
model: LoadedModel, model: LoadedModel,
tokenizer: Tokenizer,
model_dir: PathBuf, model_dir: PathBuf,
stop_token_ids: Vec<u32>,
} }
impl BackendLoadedModel for MlxLoadedModel { 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( fn build_prompt(
model: &mut LoadedModel, model: &mut LoadedModel,
model_name: &str, model_name: &str,
@ -316,28 +418,107 @@ mod imp {
fn generate_single_model( fn generate_single_model(
model: &mut LoadedModel, model: &mut LoadedModel,
prompt_array: &mlx_rs::Array, tokenizer: &Tokenizer,
prompt_array: &Array,
eos_token_ids: &[u32], eos_token_ids: &[u32],
max_tokens: usize, max_tokens: usize,
temp: f32, temp: f32,
) -> Result<(Vec<u32>, Option<DraftStats>), ProviderError> { prng_key: Option<Array>,
let mut cache: Vec<Option<ConcatKeyValueCache>> = Vec::new(); 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 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 let generator = model
.generate(&mut cache, temp, prompt_array) .generate_with_cache(&mut cache, temp, prompt_array, prng_key, stream)
.take(max_tokens); .take(max_tokens);
for token in generator { for token in generator {
let token = token.map_err(mlx_error)?; let token = token.map_err(mlx_error)?;
eval([&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) { if eos_token_ids.contains(&token_id) {
break; break;
} }
generated_ids.push(token_id); 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 { fn is_gemma4(model: &LoadedModel) -> bool {
@ -463,6 +644,8 @@ mod imp {
fn emit_generated_response( fn emit_generated_response(
generated_text: &str, generated_text: &str,
generation_prompt: &str,
enable_thinking: bool,
message_id: &str, message_id: &str,
tool_mode: ToolMode, tool_mode: ToolMode,
tx: &tokio::sync::mpsc::Sender< tx: &tokio::sync::mpsc::Sender<
@ -473,30 +656,27 @@ mod imp {
return Ok(()); return Ok(());
} }
let (content, thinking) =
split_generated_thinking(generated_text, generation_prompt, enable_thinking);
match tool_mode { match tool_mode {
ToolMode::None => { ToolMode::None => {
let mut msg = Message::assistant().with_text(generated_text); emit_assistant_message(message_id, &thinking, &content, tx)?;
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())
})?;
} }
ToolMode::Native => { 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(|_| { tx.blocking_send(Ok((Some(message), None))).map_err(|_| {
ProviderError::ExecutionError("Failed to stream MLX response".to_string()) ProviderError::ExecutionError("Failed to stream MLX response".to_string())
})?; })?;
} else { } else {
let mut msg = Message::assistant().with_text(generated_text); emit_assistant_message(message_id, &thinking, &content, tx)?;
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())
})?;
} }
} }
ToolMode::Emulated { code_mode_enabled } => { ToolMode::Emulated { code_mode_enabled } => {
emit_assistant_message(message_id, &thinking, "", tx)?;
let mut parser = StreamingEmulatorParser::new(code_mode_enabled); 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()); actions.extend(parser.flush());
for action in actions { for action in actions {
@ -510,14 +690,191 @@ mod imp {
Ok(()) Ok(())
} }
fn temperature(settings: &ModelSettings) -> f32 { struct MlxStreamEmitter<'a> {
match &settings.sampling { message_id: &'a str,
crate::local_model_registry::SamplingConfig::Greedy => 0.0, tool_mode: ToolMode,
crate::local_model_registry::SamplingConfig::Temperature { temperature, .. } => { tx: &'a tokio::sync::mpsc::Sender<
*temperature 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 { fn render_prompt(system: &str, messages: &[Message]) -> String {
@ -547,6 +904,90 @@ mod imp {
fn mlx_error(error: impl std::fmt::Display) -> ProviderError { fn mlx_error(error: impl std::fmt::Display) -> ProviderError {
ProviderError::ExecutionError(format!("MLX backend error: {}", error)) 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"))] #[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")] #[serde(rename_all = "camelCase")]
pub enum SystemNotificationType { pub enum SystemNotificationType {
ThinkingMessage, ThinkingMessage,
ProgressMessage,
InlineMessage, InlineMessage,
CreditsExhausted, CreditsExhausted,
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -2072,6 +2072,23 @@ fn send_status_message_update(
Ok(()) 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( fn status_message_from_system_notification(
notification: &SystemNotificationContent, notification: &SystemNotificationContent,
) -> Option<StatusMessage> { ) -> Option<StatusMessage> {
@ -2079,9 +2096,11 @@ fn status_message_from_system_notification(
SystemNotificationType::InlineMessage => Some(StatusMessage::Notice { SystemNotificationType::InlineMessage => Some(StatusMessage::Notice {
message: notification.msg.clone(), message: notification.msg.clone(),
}), }),
SystemNotificationType::ThinkingMessage => Some(StatusMessage::Progress { SystemNotificationType::ThinkingMessage | SystemNotificationType::ProgressMessage => {
message: notification.msg.clone(), Some(StatusMessage::Progress {
}), message: notification.msg.clone(),
})
}
SystemNotificationType::CreditsExhausted => None, 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( async fn on_load_session(
&self, &self,
cx: &ConnectionTo<Client>, cx: &ConnectionTo<Client>,
@ -2524,6 +2581,15 @@ impl GooseAcpAgent {
return Err(error); 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 user_message = Self::convert_acp_prompt_to_message(&args.prompt);
let message_text = user_message.as_concat_text(); 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() { if let Some(model_id) = model_id.as_deref() {
let model_exists = entry.default_model == model_id 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 { if !model_exists {
return Err(agent_client_protocol::Error::invalid_params().data(format!( return Err(agent_client_protocol::Error::invalid_params().data(format!(
"Model '{model_id}' is not available for provider '{provider_id}'" "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 { struct PreferenceDef {
key: PreferenceKey, key: PreferenceKey,
config_key: &'static str, config_key: &'static str,

View file

@ -875,6 +875,14 @@ impl GooseAcpAgent {
self.on_local_inference_model_delete(req).await 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)] #[custom_method(LocalInferenceModelSettingsReadRequest)]
async fn dispatch_local_inference_model_settings_read( async fn dispatch_local_inference_model_settings_read(
&self, &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( pub(super) async fn on_local_inference_model_settings_read(
&self, &self,
req: LocalInferenceModelSettingsReadRequest, req: LocalInferenceModelSettingsReadRequest,

View file

@ -68,7 +68,7 @@ use tracing::{debug, error, info, instrument, warn};
const DEFAULT_MAX_TURNS: u32 = 1000; const DEFAULT_MAX_TURNS: u32 = 1000;
const DEFAULT_STOP_HOOK_BLOCK_CAP: u32 = 8; 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 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."; 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( yield AgentEvent::Message(
Message::assistant().with_system_notification( Message::assistant().with_system_notification(
SystemNotificationType::ThinkingMessage, SystemNotificationType::ProgressMessage,
COMPACTION_THINKING_TEXT, COMPACTION_PROGRESS_TEXT,
) )
); );
@ -2060,6 +2060,17 @@ impl Agent {
} }
if let Some(response) = response { 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 { let ToolCategorizeResult {
frontend_requests, frontend_requests,
remaining_requests, remaining_requests,
@ -2427,8 +2438,8 @@ impl Agent {
); );
yield AgentEvent::Message( yield AgentEvent::Message(
Message::assistant().with_system_notification( Message::assistant().with_system_notification(
SystemNotificationType::ThinkingMessage, SystemNotificationType::ProgressMessage,
COMPACTION_THINKING_TEXT, 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 { impl Agent {
pub async fn prepare_tools_and_prompt( pub async fn prepare_tools_and_prompt(
&self, &self,
@ -345,7 +352,7 @@ impl Agent {
let (msg_opt, usage_opt) = result?; let (msg_opt, usage_opt) = result?;
if let Some(msg) = msg_opt { 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()); first_content_at = Some(std::time::Instant::now());
} }
accumulated_message = Some(match accumulated_message { accumulated_message = Some(match accumulated_message {
@ -394,7 +401,9 @@ impl Agent {
while let Some(result) = stream.next().await { while let Some(result) = stream.next().await {
let (message, mut usage) = result?; 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()); first_content_at = Some(std::time::Instant::now());
} }
if let Some(usage) = usage.as_mut() { if let Some(usage) = usage.as_mut() {
@ -656,7 +665,7 @@ pub fn is_tool_visible_to_model(tool: &Tool) -> bool {
mod tests { mod tests {
use super::*; use super::*;
use crate::config::GooseMode; use crate::config::GooseMode;
use crate::conversation::message::Message; use crate::conversation::message::{Message, SystemNotificationType};
use crate::providers::base::Provider; use crate::providers::base::Provider;
use crate::session::session_manager::SessionType; use crate::session::session_manager::SessionType;
use async_trait::async_trait; use async_trait::async_trait;
@ -1036,6 +1045,27 @@ mod tests {
usage 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] #[test]
fn fill_stream_timing_fills_both_fields_when_stats_absent() { fn fill_stream_timing_fills_both_fields_when_stats_absent() {
let request_started = Instant::now() - Duration::from_millis(100); let request_started = Instant::now() - Duration::from_millis(100);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloading": { "localInferenceSettings.downloading": {
"defaultMessage": "Wird heruntergeladen" "defaultMessage": "Wird heruntergeladen"
}, },
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": { "localInferenceSettings.featuredModels": {
"defaultMessage": "Empfohlene Modelle" "defaultMessage": "Empfohlene Modelle"
}, },
"localInferenceSettings.huggingFaceSignInNote": { "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." "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": { "localInferenceSettings.modelSettings": {
"defaultMessage": "Modelleinstellungen" "defaultMessage": "Modelleinstellungen"
}, },

View file

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

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloadProgress": { "localInferenceSettings.downloadProgress": {
"defaultMessage": "{downloaded} / {total} ({percent}%)" "defaultMessage": "{downloaded} / {total} ({percent}%)"
}, },
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": { "localInferenceSettings.featuredModels": {
"defaultMessage": "Modelos destacados" "defaultMessage": "Modelos destacados"
}, },
"localInferenceSettings.huggingFaceSignInNote": { "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." "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": { "localInferenceSettings.modelSettings": {
"defaultMessage": "Ajustes del modelo" "defaultMessage": "Ajustes del modelo"
}, },

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloading": { "localInferenceSettings.downloading": {
"defaultMessage": "Téléchargement en cours" "defaultMessage": "Téléchargement en cours"
}, },
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": { "localInferenceSettings.featuredModels": {
"defaultMessage": "Modèles en vedette" "defaultMessage": "Modèles en vedette"
}, },
"localInferenceSettings.huggingFaceSignInNote": { "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." "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": { "localInferenceSettings.modelSettings": {
"defaultMessage": "Paramètres du modèle" "defaultMessage": "Paramètres du modèle"
}, },

View file

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

View file

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

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloading": { "localInferenceSettings.downloading": {
"defaultMessage": "Download in corso" "defaultMessage": "Download in corso"
}, },
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": { "localInferenceSettings.featuredModels": {
"defaultMessage": "Modelli in evidenza" "defaultMessage": "Modelli in evidenza"
}, },
"localInferenceSettings.huggingFaceSignInNote": { "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." "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": { "localInferenceSettings.modelSettings": {
"defaultMessage": "Impostazioni modello" "defaultMessage": "Impostazioni modello"
}, },

View file

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

View file

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

View file

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

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloading": { "localInferenceSettings.downloading": {
"defaultMessage": "Baixando" "defaultMessage": "Baixando"
}, },
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": { "localInferenceSettings.featuredModels": {
"defaultMessage": "Modelos em Destaque" "defaultMessage": "Modelos em Destaque"
}, },
"localInferenceSettings.huggingFaceSignInNote": { "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." "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": { "localInferenceSettings.modelSettings": {
"defaultMessage": "Configurações do Modelo" "defaultMessage": "Configurações do Modelo"
}, },

View file

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

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloadProgress": { "localInferenceSettings.downloadProgress": {
"defaultMessage": "{downloaded} / {total} (%{percent})" "defaultMessage": "{downloaded} / {total} (%{percent})"
}, },
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": { "localInferenceSettings.featuredModels": {
"defaultMessage": "Öne Çıkan Modeller" "defaultMessage": "Öne Çıkan Modeller"
}, },
"localInferenceSettings.huggingFaceSignInNote": { "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." "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": { "localInferenceSettings.modelSettings": {
"defaultMessage": "Modeli Ayarları" "defaultMessage": "Modeli Ayarları"
}, },

View file

@ -1736,12 +1736,18 @@
"localInferenceSettings.downloading": { "localInferenceSettings.downloading": {
"defaultMessage": "Đang tải xuống" "defaultMessage": "Đang tải xuống"
}, },
"localInferenceSettings.evictFromMemory": {
"defaultMessage": "Evict from memory"
},
"localInferenceSettings.featuredModels": { "localInferenceSettings.featuredModels": {
"defaultMessage": "Mô hình nổi bật" "defaultMessage": "Mô hình nổi bật"
}, },
"localInferenceSettings.huggingFaceSignInNote": { "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." "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": { "localInferenceSettings.modelSettings": {
"defaultMessage": "Cài đặt mô hình" "defaultMessage": "Cài đặt mô hình"
}, },

View file

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

View file

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

View file

@ -93,7 +93,11 @@ type ContentIcon = {
theme?: 'light' | 'dark' | JsonObject; theme?: 'light' | 'dark' | JsonObject;
}; };
export type SystemNotificationType = 'thinkingMessage' | 'inlineMessage' | 'creditsExhausted'; export type SystemNotificationType =
| 'thinkingMessage'
| 'progressMessage'
| 'inlineMessage'
| 'creditsExhausted';
export type SystemNotificationContent = { export type SystemNotificationContent = {
data?: unknown; data?: unknown;
@ -443,17 +447,3 @@ export function hasCompletedToolCalls(message: Message): boolean {
const toolRequests = getToolRequests(message); const toolRequests = getToolRequests(message);
return toolRequests.length > 0; 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) { export function errorMessage(err: Error | unknown, default_value?: string) {
const acpData = acpErrorData(err);
if (typeof acpData === 'string') {
return acpData;
}
if (err instanceof Error) { if (err instanceof Error) {
return err.message; return err.message;
} else if (typeof err === 'object' && err !== null && 'message' in err) { } 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 { export function formatErrorForLogging(error: unknown): string {
if (error instanceof Error) { if (error instanceof Error) {
return `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ''}`; return `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ''}`;

View file

@ -121,6 +121,7 @@ import type {
LocalInferenceModelDownloadProgressResponse_unstable, LocalInferenceModelDownloadProgressResponse_unstable,
LocalInferenceModelDownloadRequest_unstable, LocalInferenceModelDownloadRequest_unstable,
LocalInferenceModelDownloadResponse_unstable, LocalInferenceModelDownloadResponse_unstable,
LocalInferenceModelEvictRequest_unstable,
LocalInferenceModelSettingsReadRequest_unstable, LocalInferenceModelSettingsReadRequest_unstable,
LocalInferenceModelSettingsReadResponse_unstable, LocalInferenceModelSettingsReadResponse_unstable,
LocalInferenceModelSettingsUpdateRequest_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( async localInferenceModelsSettingsRead_unstable(
params: LocalInferenceModelSettingsReadRequest_unstable, params: LocalInferenceModelSettingsReadRequest_unstable,
): Promise<LocalInferenceModelSettingsReadResponse_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; sizeBytes: number;
status: LocalInferenceModelDownloadStatusDto; status: LocalInferenceModelDownloadStatusDto;
recommended: boolean; recommended: boolean;
isLoaded: boolean;
settings: LocalInferenceModelSettingsDto; settings: LocalInferenceModelSettingsDto;
visionCapable: boolean; visionCapable: boolean;
mmprojStatus?: LocalInferenceModelDownloadStatusDto | null; mmprojStatus?: LocalInferenceModelDownloadStatusDto | null;
@ -2433,6 +2434,10 @@ export type LocalInferenceModelDeleteRequest_unstable = {
modelId: string; modelId: string;
}; };
export type LocalInferenceModelEvictRequest_unstable = {
modelId: string;
};
export type LocalInferenceModelSettingsReadRequest_unstable = { export type LocalInferenceModelSettingsReadRequest_unstable = {
modelId: string; modelId: string;
}; };
@ -2618,7 +2623,7 @@ export type RecipeParamsAction = 'submit' | 'cancel';
export type ExtRequest = { export type ExtRequest = {
id: string; id: string;
method: 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; [key: string]: unknown;
} | null; } | null;
}; };

View file

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