Files
tkmind_go/crates/goose-local-inference/src/mlx.rs
T

1045 lines
37 KiB
Rust

#[cfg(feature = "mlx")]
mod imp {
use std::any::Any;
use std::path::{Path, PathBuf};
use safemlx::transforms::eval;
use safemlx::{random, Array, Device, DeviceType, Stream};
use safemlx_lm::gemma4_mtp::generate_gemma4_mtp;
use safemlx_lm::models::{gemma4_assistant::load_gemma4_assistant_model, LoadedModel, Model};
use safemlx_lm_utils::tokenizer::{Chat, Conversation, Role, Tokenizer};
use serde_json::json;
use crate::backend::{BackendLoadedModel, LocalGenerationRequest, LocalInferenceBackend};
use crate::local_model_registry::{ModelSettings, ToolCallingMode};
use crate::native_tool_parsing::message_from_native_tool_text;
use crate::provider_utils::filter_extensions_from_system_prompt;
use crate::thinking_output::ThinkingOutputFilter;
use crate::tool_emulation::{
build_emulator_tool_description, load_tiny_model_prompt, message_for_emulator_action,
StreamingEmulatorParser, CODE_EXECUTION_TOOL,
};
use crate::{extract_text_content, ResolvedModelPaths};
use goose_provider_types::conversation::message::{Message, MessageContent};
use goose_provider_types::conversation::token_usage::{
DraftStats, ProviderStats, ProviderUsage, Usage,
};
use goose_provider_types::errors::ProviderError;
use goose_provider_types::formats::openai;
use goose_provider_types::images::ImageFormat;
use goose_provider_types::request_log::LoggerHandleExt;
pub(crate) const MLX_BACKEND_ID: &str = "mlx";
pub(crate) struct MlxBackend;
impl MlxBackend {
pub(crate) fn new() -> Self {
Self
}
}
impl LocalInferenceBackend for MlxBackend {
fn id(&self) -> &'static str {
MLX_BACKEND_ID
}
fn load_model(
&self,
model_id: &str,
resolved: &ResolvedModelPaths,
_settings: &ModelSettings,
) -> Result<Box<dyn BackendLoadedModel>, ProviderError> {
if !resolved.model_path.exists() {
return Err(ProviderError::ExecutionError(format!(
"Model not downloaded: {}. Please download it from Settings > Local Inference.",
model_id
)));
}
let model_dir = model_dir_from_path(&resolved.model_path)?;
let stream = Stream::new_with_device(&Device::new(DeviceType::Gpu, 0));
let weights_stream = Stream::new_with_device(&Device::new(DeviceType::Cpu, 0));
let model =
LoadedModel::load(&model_dir, &stream, &weights_stream).map_err(mlx_error)?;
let tokenizer =
Tokenizer::from_file(model_dir.join("tokenizer.json")).map_err(mlx_error)?;
tracing::info!(
backend = self.id(),
model_id,
model_type = model.model_type(),
"MLX model loaded successfully"
);
let stop_token_ids = mlx_stop_token_ids(&model, &model_dir);
Ok(Box::new(MlxLoadedModel {
model,
tokenizer,
model_dir,
stop_token_ids,
}))
}
fn generate(
&self,
loaded: &mut dyn BackendLoadedModel,
request: LocalGenerationRequest<'_>,
) -> Result<(), ProviderError> {
let loaded = loaded
.as_any_mut()
.downcast_mut::<MlxLoadedModel>()
.ok_or_else(|| {
ProviderError::ExecutionError("Loaded model backend mismatch".to_string())
})?;
let stream = Stream::new_with_device(&Device::new(DeviceType::Gpu, 0));
let tool_mode = if request.tools.is_empty() {
ToolMode::None
} else {
match request.settings.tool_calling {
ToolCallingMode::ForceNative => ToolMode::Native,
ToolCallingMode::Auto | ToolCallingMode::ForceEmulated => ToolMode::Emulated {
code_mode_enabled: request
.tools
.iter()
.any(|t| t.name == CODE_EXECUTION_TOOL),
},
}
};
let prompt = build_prompt(
&mut loaded.model,
&request.model_name,
request.system,
request.messages,
request.tools,
tool_mode,
)?;
let prompt_tokens = loaded.model.encode(&prompt, false).map_err(mlx_error)?;
if prompt_tokens.len() >= request.context_limit && request.context_limit > 0 {
return Err(ProviderError::ContextLengthExceeded(format!(
"Prompt ({} tokens) exceeds context limit ({} tokens). Try reducing conversation length.",
prompt_tokens.len(), request.context_limit
)));
}
let prompt_array = loaded
.model
.encode_to_array(&prompt, false, &stream)
.map_err(mlx_error)?;
let max_tokens = mlx_max_tokens(
request.settings,
request.max_tokens,
request.context_limit,
prompt_tokens.len(),
);
let (settings_temp, seed) = sampling(request.settings);
let temp = request.temperature.unwrap_or(settings_temp);
let prng_key = prng_key(temp, seed)?;
let eos_token_ids = loaded.stop_token_ids.clone();
let generation_started = std::time::Instant::now();
let MlxGeneration {
generated_ids,
generated_text,
draft_stats,
time_to_first_token_ms,
streamed_response,
} = if let Some(draft_model_path) = &request.draft_model_path {
if matches!(loaded.model.model_mut(), Model::Gemma4(_)) {
let weights_stream = Stream::new_with_device(&Device::new(DeviceType::Cpu, 0));
let mut assistant =
load_gemma4_assistant_model(draft_model_path, &stream, &weights_stream)
.map_err(|error| {
mlx_error(format!("failed to load MLX draft model: {error}"))
})?;
let target = match loaded.model.model_mut() {
Model::Gemma4(target) => target,
_ => unreachable!(),
};
let (ids, stats) = generate_gemma4_mtp(
target,
&mut assistant,
&prompt_array,
&eos_token_ids,
max_tokens,
temp,
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,
),
)?
};
if !streamed_response {
emit_generated_response(
&generated_text,
&prompt,
request.settings.enable_thinking,
request.message_id,
tool_mode,
request.tx,
)?;
}
let output_tokens = generated_ids.len() as i32;
let input_tokens = prompt_tokens.len() as i32;
let usage = Usage::new(
Some(input_tokens),
Some(output_tokens),
Some(input_tokens + output_tokens),
);
let log_json = serde_json::json!({
"path": "mlx",
"model_dir": loaded.model_dir,
"prompt_tokens": input_tokens,
"output_tokens": output_tokens,
"model_load_ms": request.model_load_ms,
"time_to_first_token_ms": time_to_first_token_ms,
"elapsed_ms": generation_started.elapsed().as_millis() as u64,
"generated_text": generated_text,
"draft": draft_stats,
});
let _ = request.log.write(&log_json, Some(&usage));
let stats = ProviderStats {
time_to_first_token_ms,
model_load_ms: request.model_load_ms,
elapsed_ms: Some(generation_started.elapsed().as_millis() as u64),
output_tokens: Some(generated_ids.len()),
draft: draft_stats,
};
let provider_usage = ProviderUsage::new(request.model_name, usage).with_stats(stats);
let _ = request.tx.blocking_send(Ok((None, Some(provider_usage))));
Ok(())
}
fn available_memory_bytes(&self) -> u64 {
0
}
}
#[derive(Clone, Copy)]
enum ToolMode {
None,
Native,
Emulated { code_mode_enabled: bool },
}
struct MlxGeneration {
generated_ids: Vec<u32>,
generated_text: String,
draft_stats: Option<DraftStats>,
time_to_first_token_ms: Option<u64>,
streamed_response: bool,
}
struct MlxLoadedModel {
model: LoadedModel,
tokenizer: Tokenizer,
model_dir: PathBuf,
stop_token_ids: Vec<u32>,
}
impl BackendLoadedModel for MlxLoadedModel {
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
fn model_dir_from_path(path: &Path) -> Result<PathBuf, ProviderError> {
if path.is_dir() {
Ok(path.to_path_buf())
} else {
path.parent()
.map(Path::to_path_buf)
.ok_or_else(|| mlx_error("MLX model path has no parent directory"))
}
}
fn mlx_stop_token_ids(model: &LoadedModel, model_dir: &Path) -> Vec<u32> {
let mut ids = model.eos_token_ids().to_vec();
for id in generation_config_eos_token_ids(model_dir) {
if !ids.contains(&id) {
ids.push(id);
}
}
ids
}
fn generation_config_eos_token_ids(model_dir: &Path) -> Vec<u32> {
let Ok(config_json) = std::fs::read_to_string(model_dir.join("generation_config.json"))
else {
return Vec::new();
};
let Ok(config) = serde_json::from_str::<serde_json::Value>(&config_json) else {
return Vec::new();
};
match config.get("eos_token_id") {
Some(value) => token_id_or_ids(value),
None => Vec::new(),
}
}
fn token_id_or_ids(value: &serde_json::Value) -> Vec<u32> {
if let Some(id) = value.as_u64().and_then(|id| u32::try_from(id).ok()) {
return vec![id];
}
value
.as_array()
.map(|ids| {
ids.iter()
.filter_map(|id| id.as_u64().and_then(|id| u32::try_from(id).ok()))
.collect()
})
.unwrap_or_default()
}
fn build_prompt(
model: &mut LoadedModel,
model_name: &str,
system: &str,
messages: &[Message],
tools: &[rmcp::model::Tool],
tool_mode: ToolMode,
) -> Result<String, ProviderError> {
match tool_mode {
ToolMode::Native => {
let conversations = openai_messages(system, messages);
let tool_specs = openai::format_tools(tools)
.map_err(|e| ProviderError::ExecutionError(e.to_string()))?;
if let Some(prompt) = model
.apply_chat_template_json([conversations], Some(&tool_specs), true)
.map_err(mlx_error)?
{
return Ok(prompt);
}
Ok(render_prompt(system, messages))
}
ToolMode::Emulated { code_mode_enabled } => {
let system_prompt = format!(
"{}{}",
load_tiny_model_prompt(),
build_emulator_tool_description(tools, code_mode_enabled)
);
if is_gemma4(model) {
let conversations = gemma4_messages_with_system(&system_prompt, messages);
if let Some(prompt) = model
.apply_chat_template_json([conversations], None, true)
.map_err(mlx_error)?
{
return Ok(prompt);
}
}
let conversations = chat_conversations(&system_prompt, messages);
if let Some(prompt) = model
.apply_chat_template([Chat::Owned(conversations)], None, true)
.map_err(mlx_error)?
{
return Ok(prompt);
}
Ok(render_prompt(&system_prompt, messages))
}
ToolMode::None => {
if is_gemma4(model) {
let conversations = gemma4_messages(model_name, system, messages);
if let Some(prompt) = model
.apply_chat_template_json([conversations], None, true)
.map_err(mlx_error)?
{
return Ok(prompt);
}
}
let conversations = chat_conversations(system, messages);
if let Some(prompt) = model
.apply_chat_template([Chat::Owned(conversations)], None, true)
.map_err(mlx_error)?
{
return Ok(prompt);
}
Ok(render_prompt(system, messages))
}
}
}
fn generate_single_model(
model: &mut LoadedModel,
tokenizer: &Tokenizer,
prompt_array: &Array,
eos_token_ids: &[u32],
max_tokens: usize,
temp: f32,
prng_key: Option<Array>,
stream: &Stream,
generation_started: std::time::Instant,
mut emitter: MlxStreamEmitter<'_>,
) -> Result<MlxGeneration, ProviderError> {
let mut cache = model.new_cache();
let mut generated_ids = Vec::new();
let mut streamed_text = String::new();
let mut time_to_first_token_ms = None;
let stream_generation = emitter.can_stream();
let mut decode_stream = tokenizer.decode_stream(true);
{
let generator = model
.generate_with_cache(&mut cache, temp, prompt_array, prng_key, stream)
.take(max_tokens);
for token in generator {
let token = token.map_err(mlx_error)?;
eval([&token]).map_err(mlx_error)?;
let token_id = token.item::<u32>(stream);
time_to_first_token_ms.get_or_insert_with(|| {
u64::try_from(generation_started.elapsed().as_millis()).unwrap_or(u64::MAX)
});
if eos_token_ids.contains(&token_id) {
break;
}
generated_ids.push(token_id);
if stream_generation {
if let Some(piece) = decode_stream.step(token_id).map_err(mlx_error)? {
if !piece.is_empty() {
let should_continue = emitter.push_text(&piece)?;
streamed_text.push_str(&piece);
if !should_continue {
break;
}
}
}
}
}
}
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 {
matches!(model.model_type(), "gemma4" | "gemma4_text")
}
fn gemma4_messages(
model_name: &str,
system: &str,
messages: &[Message],
) -> Vec<serde_json::Value> {
let system = gemma4_system_prompt(model_name, system);
gemma4_messages_with_optional_system(system.as_deref(), messages)
}
fn gemma4_messages_with_system(system: &str, messages: &[Message]) -> Vec<serde_json::Value> {
gemma4_messages_with_optional_system(Some(system), messages)
}
fn gemma4_messages_with_optional_system(
system: Option<&str>,
messages: &[Message],
) -> Vec<serde_json::Value> {
let mut values = Vec::new();
if let Some(system) = system.map(str::trim).filter(|system| !system.is_empty()) {
values.push(json!({
"role": "system",
"content": system,
}));
}
for message in messages.iter().filter(|message| message.is_agent_visible()) {
let text = extract_text_content(message);
if text.trim().is_empty() {
continue;
}
match message.role {
rmcp::model::Role::User => values.push(json!({
"role": "user",
"content": [{"type": "text", "text": text.trim(), "content": text.trim()}],
})),
rmcp::model::Role::Assistant => values.push(json!({
"role": "assistant",
"content": text.trim(),
})),
}
}
values
}
fn gemma4_system_prompt(model_name: &str, system: &str) -> Option<String> {
if should_use_tiny_system_prompt(model_name) {
return Some(load_tiny_model_prompt());
}
let filtered = filter_extensions_from_system_prompt(system);
let system = filtered.trim();
if system.is_empty() {
None
} else {
Some(system.to_string())
}
}
fn should_use_tiny_system_prompt(model_name: &str) -> bool {
estimate_model_size_billions(model_name).is_some_and(|size| size <= 4.0)
}
fn estimate_model_size_billions(model_name: &str) -> Option<f32> {
let normalized = model_name.to_ascii_lowercase().replace('-', "_");
for part in normalized.split('_') {
if let Some(value) = part.strip_suffix('b') {
if let Ok(size) = value.parse::<f32>() {
return Some(size);
}
}
if let Some(value) = part
.strip_prefix('e')
.and_then(|value| value.strip_suffix('b'))
{
if let Ok(size) = value.parse::<f32>() {
return Some(size);
}
}
}
None
}
fn openai_messages(system: &str, messages: &[Message]) -> Vec<serde_json::Value> {
let mut values = vec![serde_json::json!({
"role": "system",
"content": system,
})];
values.extend(openai::format_messages(messages, &ImageFormat::OpenAi));
values
}
fn chat_conversations(system: &str, messages: &[Message]) -> Vec<Conversation<Role, String>> {
let mut conversations = Vec::new();
if !system.trim().is_empty() {
conversations.push(Conversation {
role: Role::System,
content: system.trim().to_string(),
});
}
for message in messages.iter().filter(|message| message.is_agent_visible()) {
let role = match message.role {
rmcp::model::Role::User => Role::User,
rmcp::model::Role::Assistant => Role::Assistant,
};
let text = extract_text_content(message);
if !text.trim().is_empty() {
conversations.push(Conversation {
role,
content: text.trim().to_string(),
});
}
}
conversations
}
fn emit_generated_response(
generated_text: &str,
generation_prompt: &str,
enable_thinking: bool,
message_id: &str,
tool_mode: ToolMode,
tx: &tokio::sync::mpsc::Sender<
Result<(Option<Message>, Option<ProviderUsage>), ProviderError>,
>,
) -> Result<(), ProviderError> {
if generated_text.is_empty() {
return Ok(());
}
let (content, thinking) =
split_generated_thinking(generated_text, generation_prompt, enable_thinking);
match tool_mode {
ToolMode::None => {
emit_assistant_message(message_id, &thinking, &content, tx)?;
}
ToolMode::Native => {
if let Some(mut message) = message_from_native_tool_text(&content, message_id)? {
prepend_thinking(&mut message, &thinking);
tx.blocking_send(Ok((Some(message), None))).map_err(|_| {
ProviderError::ExecutionError("Failed to stream MLX response".to_string())
})?;
} else {
emit_assistant_message(message_id, &thinking, &content, tx)?;
}
}
ToolMode::Emulated { code_mode_enabled } => {
emit_assistant_message(message_id, &thinking, "", tx)?;
let mut parser = StreamingEmulatorParser::new(code_mode_enabled);
let mut actions = parser.process_chunk(&content);
actions.extend(parser.flush());
for action in actions {
let (message, _) = message_for_emulator_action(&action, message_id);
tx.blocking_send(Ok((Some(message), None))).map_err(|_| {
ProviderError::ExecutionError("Failed to stream MLX response".to_string())
})?;
}
}
}
Ok(())
}
struct MlxStreamEmitter<'a> {
message_id: &'a str,
tool_mode: ToolMode,
tx: &'a tokio::sync::mpsc::Sender<
Result<(Option<Message>, Option<ProviderUsage>), ProviderError>,
>,
output_filter: ThinkingOutputFilter,
emulator_parser: Option<StreamingEmulatorParser>,
stop_after_tool_call: bool,
}
impl<'a> MlxStreamEmitter<'a> {
fn new(
message_id: &'a str,
tool_mode: ToolMode,
enable_thinking: bool,
generation_prompt: &str,
tx: &'a tokio::sync::mpsc::Sender<
Result<(Option<Message>, Option<ProviderUsage>), ProviderError>,
>,
) -> Self {
let emulator_parser = match tool_mode {
ToolMode::Emulated { code_mode_enabled } => {
Some(StreamingEmulatorParser::new(code_mode_enabled))
}
ToolMode::None | ToolMode::Native => None,
};
Self {
message_id,
tool_mode,
tx,
output_filter: ThinkingOutputFilter::new(enable_thinking, generation_prompt),
emulator_parser,
stop_after_tool_call: false,
}
}
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 {
let mut prompt = String::new();
if !system.trim().is_empty() {
prompt.push_str("System: ");
prompt.push_str(system.trim());
prompt.push('\n');
}
for message in messages.iter().filter(|message| message.is_agent_visible()) {
let role = match message.role {
rmcp::model::Role::User => "User",
rmcp::model::Role::Assistant => "Assistant",
};
let text = extract_text_content(message);
if !text.trim().is_empty() {
prompt.push_str(role);
prompt.push_str(": ");
prompt.push_str(text.trim());
prompt.push('\n');
}
}
prompt.push_str("Assistant: ");
prompt
}
fn mlx_error(error: impl std::fmt::Display) -> ProviderError {
ProviderError::ExecutionError(format!("MLX backend error: {}", error))
}
#[cfg(test)]
mod tests {
use super::{
final_stream_suffix, mlx_max_tokens, split_generated_thinking, token_id_or_ids,
};
use crate::local_model_registry::ModelSettings;
use serde_json::json;
#[test]
fn extracts_thinking_started_by_generation_prompt() {
let (content, thinking) = split_generated_thinking(
"hidden reasoning</think>visible answer",
"<|im_start|>assistant\n<think>\n",
true,
);
assert_eq!(thinking.trim(), "hidden reasoning");
assert_eq!(content, "visible answer");
}
#[test]
fn leaves_think_tags_as_content_when_thinking_disabled() {
let generated = "hidden reasoning</think>visible answer";
let (content, thinking) =
split_generated_thinking(generated, "<|im_start|>assistant\n<think>\n", false);
assert!(thinking.is_empty());
assert_eq!(content, generated);
}
#[test]
fn parses_single_and_multiple_eos_token_ids() {
assert_eq!(token_id_or_ids(&json!(248044)), vec![248044]);
assert_eq!(
token_id_or_ids(&json!([248046, 248044])),
vec![248046, 248044]
);
}
#[test]
fn final_stream_suffix_flushes_append_only_suffix() {
assert_eq!(
final_stream_suffix("hello world", "hello").unwrap(),
Some(" world")
);
}
#[test]
fn final_stream_suffix_does_not_replay_fully_streamed_text() {
assert_eq!(
final_stream_suffix("run tool", "run tool").unwrap(),
Some("")
);
}
#[test]
fn final_stream_suffix_allows_unstreamed_fallback() {
assert_eq!(final_stream_suffix("hello world", "").unwrap(), None);
}
#[test]
fn final_stream_suffix_rejects_rewritten_streamed_prefix() {
assert!(final_stream_suffix("corrected response", "stale prefix").is_err());
}
#[test]
fn max_tokens_defaults_to_context_headroom() {
let settings = ModelSettings::default();
assert_eq!(mlx_max_tokens(&settings, None, 128_000, 1_752), 126_248);
}
#[test]
fn max_tokens_respects_configured_caps() {
let mut settings = ModelSettings::default();
settings.max_output_tokens = Some(2048);
assert_eq!(mlx_max_tokens(&settings, None, 128_000, 1_752), 2048);
let settings = ModelSettings::default();
assert_eq!(mlx_max_tokens(&settings, Some(1024), 128_000, 1_752), 1024);
}
}
}
#[cfg(not(feature = "mlx"))]
mod imp {
use crate::backend::{BackendLoadedModel, LocalGenerationRequest, LocalInferenceBackend};
use crate::local_model_registry::ModelSettings;
use crate::ResolvedModelPaths;
use goose_provider_types::errors::ProviderError;
pub(crate) const MLX_BACKEND_ID: &str = "mlx";
pub(crate) struct MlxBackend;
impl MlxBackend {
pub(crate) fn new() -> Self {
Self
}
}
impl LocalInferenceBackend for MlxBackend {
fn id(&self) -> &'static str {
MLX_BACKEND_ID
}
fn load_model(
&self,
_model_id: &str,
_resolved: &ResolvedModelPaths,
_settings: &ModelSettings,
) -> Result<Box<dyn BackendLoadedModel>, ProviderError> {
Err(ProviderError::ExecutionError(
"MLX backend support was not compiled in. Rebuild with the `mlx` feature."
.to_string(),
))
}
fn generate(
&self,
_loaded: &mut dyn BackendLoadedModel,
_request: LocalGenerationRequest<'_>,
) -> Result<(), ProviderError> {
Err(ProviderError::ExecutionError(
"MLX backend support was not compiled in. Rebuild with the `mlx` feature."
.to_string(),
))
}
fn available_memory_bytes(&self) -> u64 {
0
}
}
}
pub(crate) use imp::{MlxBackend, MLX_BACKEND_ID};