From 1420e3abf686448776e11d607556f72fcf0ce006 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sat, 17 Oct 2026 15:25:36 +0530 Subject: [PATCH 01/14] sdk: consume kit 0.20.40 (IDL 1.6.0, plugin API 14) and its Swift ref --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- src/sys/bindings.rs | 67 +++++++++++++++++-- .../expected/version__--json__version.stdout | 2 +- tests/golden/expected/version__version.stdout | 4 +- .../expected/version__version__--json.stdout | 2 +- versions.toml | 23 ++++--- 7 files changed, 80 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4438753b..55c5ada0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,7 @@ jobs: with: persist-credentials: false repository: RunanywhereAI/runanywhere-sdks - ref: e595580a72452595a4a5b8abc137cad10132ba75 + ref: c0cc8ebd416b8d4b675f2b6457fe3d4232cc093e path: .deps/runanywhere-sdks - name: Install ninja run: brew install ninja diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e61a571..ae67a72a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,7 +78,7 @@ jobs: with: persist-credentials: false repository: RunanywhereAI/runanywhere-sdks - ref: e595580a72452595a4a5b8abc137cad10132ba75 + ref: c0cc8ebd416b8d4b675f2b6457fe3d4232cc093e path: .deps/runanywhere-sdks - name: Install ninja run: brew install ninja diff --git a/src/sys/bindings.rs b/src/sys/bindings.rs index 4ef55851..102a7481 100644 --- a/src/sys/bindings.rs +++ b/src/sys/bindings.rs @@ -1,4 +1,4 @@ -// @generated by scripts/build/gen-sys-bindings.sh from RunAnywhere kit 0.20.39. +// @generated by scripts/build/gen-sys-bindings.sh from RunAnywhere kit 0.20.40. // DO NOT EDIT. Regenerate after a kit pin bump. /* automatically generated by rust-bindgen 0.73.2 */ @@ -90,6 +90,10 @@ pub type rac_decision_question_type = ::std::os::raw::c_uint; #[doc = " One candidate answer for a question. Both pointers stay caller-owned for the\n duration of the decide call."] pub type rac_decision_option_t = rac_decision_option; pub type rac_decision_question_t = rac_decision_question; +#[doc = " Mirrors `runanywhere.v1.DecisionGrouping`."] +pub type rac_decision_grouping = ::std::os::raw::c_uint; +#[doc = " One encoded image (PNG, JPEG, WebP or HEIC). Borrowed, never owned."] +pub type rac_decision_image_t = rac_decision_image; pub type rac_decision_options_t = rac_decision_options; pub type rac_decision_answer_t = rac_decision_answer; pub type rac_decision_result_t = rac_decision_result; @@ -1227,6 +1231,15 @@ pub struct rac_decision_question { pub options: *const rac_decision_option_t, pub option_count: usize, } +#[doc = " One encoded image (PNG, JPEG, WebP or HEIC). Borrowed, never owned."] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct rac_decision_image { + pub data: *const u8, + pub size: usize, + #[doc = " MIME type such as \"image/png\"; NULL or empty = sniffed from the bytes."] + pub mime_type: *const ::std::os::raw::c_char, +} #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct rac_decision_options { @@ -1234,6 +1247,17 @@ pub struct rac_decision_options { pub temperature: f32, #[doc = " Prompt-wording version the caller requires; 0 = the model's own served\n version. A model that serves a different version refuses the request\n (RAC_ERROR_NOT_SUPPORTED) rather than scoring with unknown wording."] pub prompt_format_version: u32, + #[doc = " How the questions share forward passes; UNSPECIFIED = the model's\n default. A model with one fixed layout refuses any other value\n (RAC_ERROR_NOT_SUPPORTED)."] + pub grouping: rac_decision_grouping_t, + #[doc = " Token cap per sequence; 0 = the model's limit. Unsupported non-zero is refused."] + pub max_length: u32, + #[doc = " Image pixel budget per image; 0 = the model's own. Unsupported non-zero is refused."] + pub max_pixels: u32, + #[doc = " Lower image pixel bound; 0 = the model's own. Unsupported non-zero is refused."] + pub min_pixels: u32, + #[doc = " Images shared by every question, borrowed for the duration of the call.\n A model without image input refuses a non-empty list."] + pub images: *const rac_decision_image_t, + pub image_count: usize, } #[repr(C)] #[derive(Debug, Copy, Clone)] @@ -3923,6 +3947,9 @@ pub const RAC_DEFAULT_LLM_GENERATION_OPTIONS_MIN_P: f64 = 0.05; pub const RAC_DEFAULT_LLM_CONFIGURATION_CONTEXT_LENGTH: u32 = 2048; pub const RAC_DEFAULT_DECISION_OPTIONS_TEMPERATURE: f64 = 0.0; pub const RAC_DEFAULT_DECISION_OPTIONS_PROMPT_FORMAT_VERSION: u32 = 0; +pub const RAC_DEFAULT_DECISION_OPTIONS_MAX_LENGTH: u32 = 0; +pub const RAC_DEFAULT_DECISION_OPTIONS_MAX_PIXELS: u32 = 0; +pub const RAC_DEFAULT_DECISION_OPTIONS_MIN_PIXELS: u32 = 0; pub const RAC_DEFAULT_DIARIZATION_OPTIONS_SAMPLE_RATE: u32 = 16000; pub const RAC_DEFAULT_DIARIZATION_OPTIONS_CHANNELS: u32 = 1; pub const RAC_DEFAULT_DIARIZATION_OPTIONS_THRESHOLD: f64 = 0.5; @@ -4070,7 +4097,7 @@ pub const RAC_ENDPOINT_HEALTH: &[u8; 11] = b"/v1/health\0"; pub const RAC_ENDPOINT_DEVICE_REGISTER: &[u8; 25] = b"/api/v1/devices/register\0"; pub const RAC_ENDPOINT_TELEMETRY_V2_PREFIX: &[u8; 23] = b"/api/v2/sdk/telemetry/\0"; pub const RAC_ENDPOINT_MODELS_AVAILABLE: &[u8; 25] = b"/api/v1/models/available\0"; -pub const RAC_PLUGIN_API_VERSION: u32 = 13; +pub const RAC_PLUGIN_API_VERSION: u32 = 14; pub const RAC_ERROR_SERVER_ALREADY_RUNNING: i32 = -200; pub const RAC_ERROR_SERVER_NOT_RUNNING: i32 = -201; pub const RAC_ERROR_SERVER_BIND_FAILED: i32 = -202; @@ -4383,14 +4410,42 @@ const _: () = { ["Offset of field: rac_decision_question::option_count"] [::std::mem::offset_of!(rac_decision_question, option_count) - 32usize]; }; +pub const RAC_DECISION_GROUPING_UNSPECIFIED: rac_decision_grouping = 0; +#[doc = " Every question shares one sequence (split only when over length)."] +pub const RAC_DECISION_GROUPING_JOINT: rac_decision_grouping = 1; +#[doc = " Each question runs in its own sequence with the full state and images."] +pub const RAC_DECISION_GROUPING_PER_QUESTION: rac_decision_grouping = 2; +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of rac_decision_image"][::std::mem::size_of::() - 24usize]; + ["Alignment of rac_decision_image"][::std::mem::align_of::() - 8usize]; + ["Offset of field: rac_decision_image::data"] + [::std::mem::offset_of!(rac_decision_image, data) - 0usize]; + ["Offset of field: rac_decision_image::size"] + [::std::mem::offset_of!(rac_decision_image, size) - 8usize]; + ["Offset of field: rac_decision_image::mime_type"] + [::std::mem::offset_of!(rac_decision_image, mime_type) - 16usize]; +}; #[allow(clippy::unnecessary_operation, clippy::identity_op)] const _: () = { - ["Size of rac_decision_options"][::std::mem::size_of::() - 8usize]; - ["Alignment of rac_decision_options"][::std::mem::align_of::() - 4usize]; + ["Size of rac_decision_options"][::std::mem::size_of::() - 40usize]; + ["Alignment of rac_decision_options"][::std::mem::align_of::() - 8usize]; ["Offset of field: rac_decision_options::temperature"] [::std::mem::offset_of!(rac_decision_options, temperature) - 0usize]; ["Offset of field: rac_decision_options::prompt_format_version"] [::std::mem::offset_of!(rac_decision_options, prompt_format_version) - 4usize]; + ["Offset of field: rac_decision_options::grouping"] + [::std::mem::offset_of!(rac_decision_options, grouping) - 8usize]; + ["Offset of field: rac_decision_options::max_length"] + [::std::mem::offset_of!(rac_decision_options, max_length) - 12usize]; + ["Offset of field: rac_decision_options::max_pixels"] + [::std::mem::offset_of!(rac_decision_options, max_pixels) - 16usize]; + ["Offset of field: rac_decision_options::min_pixels"] + [::std::mem::offset_of!(rac_decision_options, min_pixels) - 20usize]; + ["Offset of field: rac_decision_options::images"] + [::std::mem::offset_of!(rac_decision_options, images) - 24usize]; + ["Offset of field: rac_decision_options::image_count"] + [::std::mem::offset_of!(rac_decision_options, image_count) - 32usize]; }; #[allow(clippy::unnecessary_operation, clippy::identity_op)] const _: () = { @@ -7370,6 +7425,8 @@ pub use self::rac_audio_format_enum as rac_audio_format_enum_t; pub use self::rac_audio_pipeline_state as rac_audio_pipeline_state_t; #[doc = " Capability types supported by backends.\n These match the capabilities defined in runanywhere-core."] pub use self::rac_capability as rac_capability_t; +#[doc = " Mirrors `runanywhere.v1.DecisionGrouping`."] +pub use self::rac_decision_grouping as rac_decision_grouping_t; #[doc = " The kind of answer a question expects. Selects both the shape of the answer\n and the calibration formula used for its confidence."] pub use self::rac_decision_question_type as rac_decision_question_type_t; #[doc = " Device type for backend execution."] @@ -10022,7 +10079,7 @@ unsafe extern "C" { pub fn rac_server_is_running() -> rac_bool_t; #[doc = " @brief Get server status\n\n @param status Output parameter for status (must not be NULL)\n @return RAC_SUCCESS on success"] pub fn rac_server_get_status(status: *mut rac_server_status_t) -> rac_result_t; - #[doc = " @brief Get the context length of the loaded model\n\n Returns the context length the backend actually allocated after device\n fitting and model limits. This can be smaller than\n rac_server_config_t.context_size.\n\n @param context_length Output context length in tokens (must not be NULL)\n @return RAC_SUCCESS on success, RAC_ERROR_SERVER_NOT_RUNNING when stopped,\n or RAC_ERROR_BACKEND_UNAVAILABLE when the backend cannot report it"] + #[doc = " @brief Get the context length of the loaded model\n\n Returns the context length reported by the loaded backend. This value may\n differ from rac_server_config_t.context_size and may not reflect device\n fitting.\n\n @param context_length Output context length in tokens (must not be NULL)\n @return RAC_SUCCESS on success, RAC_ERROR_SERVER_NOT_RUNNING when stopped,\n or RAC_ERROR_BACKEND_UNAVAILABLE when the backend cannot report it"] pub fn rac_server_get_context_length(context_length: *mut i32) -> rac_result_t; #[doc = " @brief Block until the server stops\n\n Useful for main() to keep the process alive while serving requests.\n Returns when rac_server_stop() is called or on error.\n\n @return Exit code (0 on clean shutdown)"] pub fn rac_server_wait() -> ::std::os::raw::c_int; diff --git a/tests/golden/expected/version__--json__version.stdout b/tests/golden/expected/version__--json__version.stdout index 917fe08d..0e43c774 100644 --- a/tests/golden/expected/version__--json__version.stdout +++ b/tests/golden/expected/version__--json__version.stdout @@ -1 +1 @@ -{"wally":"","commons":"0.20.39","pinned_sdk":"0.20.39","idl_version":"1.5.0","idl_schema_sha256":"faca2c22e518f3d41e534399b854bbbc9617d4a76511592a6013a888fdd3cf3d","idl_protoc":"35.1","pin_ok":true} +{"wally":"","commons":"0.20.40","pinned_sdk":"0.20.40","idl_version":"1.6.0","idl_schema_sha256":"859d5ea75645ca0268e1348a9968813937ea5be0e35340768b0c07786e0993b6","idl_protoc":"35.1","pin_ok":true} diff --git a/tests/golden/expected/version__version.stdout b/tests/golden/expected/version__version.stdout index 68e0bbec..6ef6006c 100644 --- a/tests/golden/expected/version__version.stdout +++ b/tests/golden/expected/version__version.stdout @@ -1,2 +1,2 @@ -wally (commons 0.20.39) -idl 1.5.0 sha256 faca2c22e518f3d41e534399b854bbbc9617d4a76511592a6013a888fdd3cf3d +wally (commons 0.20.40) +idl 1.6.0 sha256 859d5ea75645ca0268e1348a9968813937ea5be0e35340768b0c07786e0993b6 diff --git a/tests/golden/expected/version__version__--json.stdout b/tests/golden/expected/version__version__--json.stdout index 917fe08d..0e43c774 100644 --- a/tests/golden/expected/version__version__--json.stdout +++ b/tests/golden/expected/version__version__--json.stdout @@ -1 +1 @@ -{"wally":"","commons":"0.20.39","pinned_sdk":"0.20.39","idl_version":"1.5.0","idl_schema_sha256":"faca2c22e518f3d41e534399b854bbbc9617d4a76511592a6013a888fdd3cf3d","idl_protoc":"35.1","pin_ok":true} +{"wally":"","commons":"0.20.40","pinned_sdk":"0.20.40","idl_version":"1.6.0","idl_schema_sha256":"859d5ea75645ca0268e1348a9968813937ea5be0e35340768b0c07786e0993b6","idl_protoc":"35.1","pin_ok":true} diff --git a/versions.toml b/versions.toml index dc419209..4d702ff4 100644 --- a/versions.toml +++ b/versions.toml @@ -16,16 +16,16 @@ decisions_contract_sha256 = "6cd5afaa222e55337dcab1fa4b2bbee2763cea707e18d539a5c [sdk] # The published C++ desktop kit this tree builds against; bump the SHAs with it. -kit_version = "0.20.39" -kit_release_tag = "cpp-desktop-v0.20.39" -idl_version = "1.5.0" -idl_schema_sha256 = "faca2c22e518f3d41e534399b854bbbc9617d4a76511592a6013a888fdd3cf3d" +kit_version = "0.20.40" +kit_release_tag = "cpp-desktop-v0.20.40" +idl_version = "1.6.0" +idl_schema_sha256 = "859d5ea75645ca0268e1348a9968813937ea5be0e35340768b0c07786e0993b6" idl_protoc_version = "35.1" -kit_sha256_macos_arm64 = "1262fa1a54be0945c862d66565afeadcee8d840dac3c65a0649838fba62ffda6" -kit_sha256_windows_x64 = "917c0798706f237a0668f45b1a78915d3b44150b19659347a4641ea7830bf51a" -kit_sha256_windows_arm64 = "10964d413eda749698e8a2d2afe9438182d2748b1b46a38c82b7f91ea1622653" -kit_sha256_linux_x64 = "fa9080f282c3cc494ee5a9d1fdfa0e8fc01a621292b44b6ccd9d3140c7853eed" -kit_sha256_linux_arm64 = "7492758d20e584f56b99be9d262959c4c512011ae13337a34b49bf44498ec5e6" +kit_sha256_macos_arm64 = "e5a3f10d2ab6023e3d7c599c2a4ca2d05a083d294ac11f866910de0d581bb2f7" +kit_sha256_windows_x64 = "05720991852c948ce8c45462e025ab410d05fdce852ce1e5410e5c1b70cc0312" +kit_sha256_windows_arm64 = "ee9c1461701fe27ae409b5f91075e48343a0cc26ed02321b963a4da999e5680a" +kit_sha256_linux_x64 = "a68b5943315780b4ce845d13966ef592f76595bf2fe9d21cc76e89385df7f35b" +kit_sha256_linux_arm64 = "22784d3e99752346e00917a92515ab5348d43093c9bbfe5e71954d7fb9a8d70b" [swift] # The Apple MLX host's Swift SDK: sdk_package_version pins swift/Package.swift, @@ -33,13 +33,14 @@ kit_sha256_linux_arm64 = "7492758d20e584f56b99be9d262959c4c512011ae13337a34b49bf # used by ci.yml/release.yml. Both move on their own cadence, distinct from the # C++ kit above. # -# sdk_ci_ref is the v0.20.38 commit itself, not the tag name: release tags on +# sdk_ci_ref is the commit cpp-desktop-v0.20.40 was built from, not a tag +# name: release tags on # that repo are not protected or immutable, and this checkout supplies the # Swift code compiled into the Apple bottle, so a retargeted tag would change # what ships. The SHA is what the tag resolved to when this pin was made. sdk_package_version = "0.20.37" tools_version = "6.0" -sdk_ci_ref = "e595580a72452595a4a5b8abc137cad10132ba75" +sdk_ci_ref = "c0cc8ebd416b8d4b675f2b6457fe3d4232cc093e" [toolchain] # CMake's own floor; CMakeLists.txt declares these and check-versions holds it here. From d5d946fbe3b7c3be49cc7f35ca1a4e9c15631592 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sat, 17 Oct 2026 15:25:36 +0530 Subject: [PATCH 02/14] decisions: local --image, --grouping, --max-length, --max-pixels and --min-pixels --- src/commands/cmd_decisions.rs | 285 ++++++++++++++++--- tests/golden/expected/help__decide.stdout | 11 +- tests/golden/expected/help__decisions.stdout | 11 +- tests/test_decisions_cli.py | 5 +- 4 files changed, 273 insertions(+), 39 deletions(-) diff --git a/src/commands/cmd_decisions.rs b/src/commands/cmd_decisions.rs index e0bcef4c..4a9a0e47 100644 --- a/src/commands/cmd_decisions.rs +++ b/src/commands/cmd_decisions.rs @@ -80,6 +80,101 @@ fn base64(bytes: &[u8]) -> String { out } +/// The bytes and MIME type of a contract image data URL. The inverse of +/// `image_data_url`; anything else in the URL is refused rather than guessed. +fn decode_image_data_url(url: &str) -> Result<(Vec, String), String> { + let (prefix, encoded) = IMAGE_URL_PREFIXES + .iter() + .find_map(|prefix| url.strip_prefix(prefix).map(|rest| (*prefix, rest))) + .ok_or_else(|| "images must be base64 PNG, JPEG or WebP data URLs".to_string())?; + let mime = prefix["data:".len()..prefix.len() - ";base64,".len()].to_string(); + let value = |c: u8| -> Option { + match c { + b'A'..=b'Z' => Some(u32::from(c - b'A')), + b'a'..=b'z' => Some(u32::from(c - b'a') + 26), + b'0'..=b'9' => Some(u32::from(c - b'0') + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + }; + let bytes = encoded.as_bytes(); + if bytes.is_empty() || bytes.len() % 4 != 0 { + return Err("image data URL is not padded base64".to_string()); + } + let mut out = Vec::with_capacity(bytes.len() / 4 * 3); + for (index, chunk) in bytes.chunks(4).enumerate() { + let last = index + 1 == bytes.len() / 4; + let padding = chunk.iter().rev().take_while(|&&c| c == b'=').count(); + if padding > 2 || (padding > 0 && !last) { + return Err("image data URL is not padded base64".to_string()); + } + let mut n = 0u32; + for &c in &chunk[..4 - padding] { + n = (n << 6) | value(c).ok_or("image data URL is not base64")?; + } + n <<= 6 * padding as u32; + out.extend_from_slice(&n.to_be_bytes()[1..4 - padding]); + } + Ok((out, mime)) +} + +/// Settings only a local decision model takes. Each is a request: a model +/// that cannot honour one refuses the whole call, naming it. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct LocalControls { + pub grouping: v1::DecisionGrouping, + /// 0 = the model's limit. + pub max_length: u32, + /// 0 = the model's own budget. + pub max_pixels: u32, + /// 0 = the model's own floor. + pub min_pixels: u32, +} + +impl LocalControls { + const FLAGS: [&'static str; 4] = ["--grouping", "--max-length", "--max-pixels", "--min-pixels"]; + + fn from_flags(p: &crate::cli::Parsed) -> Result { + let grouping = match p.get_str("--grouping").as_deref() { + None => v1::DecisionGrouping::Unspecified, + Some("joint") => v1::DecisionGrouping::Joint, + Some("per-question") => v1::DecisionGrouping::PerQuestion, + Some(other) => { + return Err(format!( + "--grouping must be 'joint' or 'per-question', not '{other}'" + )) + } + }; + let positive = |name: &str| -> Result { + match p.get_u64(name) { + None => Ok(0), + Some(0) => Err(format!("{name} must be greater than 0")), + Some(value) => { + u32::try_from(value).map_err(|_| format!("{name} must be at most {}", u32::MAX)) + } + } + }; + let controls = Self { + grouping, + max_length: positive("--max-length")?, + max_pixels: positive("--max-pixels")?, + min_pixels: positive("--min-pixels")?, + }; + if controls.min_pixels != 0 + && controls.max_pixels != 0 + && controls.min_pixels > controls.max_pixels + { + return Err("--min-pixels must not exceed --max-pixels".to_string()); + } + Ok(controls) + } + + fn is_set(&self) -> bool { + *self != Self::default() + } +} + /// An image file as the data URL the contract takes, at its full size, its /// type read from its bytes rather than its name. A HEIC photo is sent as a /// JPEG made from it. The gateway judges every image's size, except one whose @@ -823,7 +918,10 @@ fn local_response( /// options map name→key (the name IS the canonical key the renderer expects); /// score levels map to ordered keys "0".."n-1" with the level text as the /// description, so the answer's `legend` can label the expected level. -fn local_request(request: &contract::DecisionsRequest) -> v1::DecisionRequest { +fn local_request( + request: &contract::DecisionsRequest, + controls: &LocalControls, +) -> Result { let questions = request .questions .iter() @@ -873,20 +971,34 @@ fn local_request(request: &contract::DecisionsRequest) -> v1::DecisionRequest { }) .collect(); - let options = - (request.temperature.is_some() || request.prompt_format_version.is_some()).then(|| { - v1::DecisionOptions { - temperature: request.temperature.unwrap_or(0.0) as f32, - prompt_format_version: request.prompt_format_version.unwrap_or(0) as u32, - } - }); + let options = (request.temperature.is_some() + || request.prompt_format_version.is_some() + || controls.is_set()) + .then(|| v1::DecisionOptions { + temperature: request.temperature.unwrap_or(0.0) as f32, + prompt_format_version: request.prompt_format_version.unwrap_or(0) as u32, + grouping: controls.grouping as i32, + max_length: controls.max_length, + max_pixels: controls.max_pixels, + min_pixels: controls.min_pixels, + }); + let images = request + .images + .iter() + .flatten() + .map(|url| { + decode_image_data_url(url) + .map(|(data, mime_type)| v1::DecisionImage { data, mime_type }) + }) + .collect::, _>>()?; - v1::DecisionRequest { + Ok(v1::DecisionRequest { state: request.input.clone(), questions, options, model_id: (!request.model.is_empty()).then(|| request.model.clone()), - } + images, + }) } /// Score through the local component: resolve the model, load it through the @@ -951,7 +1063,15 @@ pub(crate) fn score_loaded( loaded: &LoadedDecisions, request: &contract::DecisionsRequest, ) -> Result { - let proto_request = local_request(request); + score_loaded_with(loaded, request, &LocalControls::default()) +} + +pub(crate) fn score_loaded_with( + loaded: &LoadedDecisions, + request: &contract::DecisionsRequest, + controls: &LocalControls, +) -> Result { + let proto_request = local_request(request, controls)?; let bytes = serialize(&proto_request); let mut out_buffer = ProtoBuffer::new(); // SAFETY: handle is live and loaded; bytes/out_buffer are valid for the @@ -999,7 +1119,12 @@ struct LocalFailure { already_reported: bool, } -fn run_local(options: &GlobalOptions, request: &contract::DecisionsRequest, json: bool) -> i32 { +fn run_local( + options: &GlobalOptions, + request: &contract::DecisionsRequest, + controls: &LocalControls, + json: bool, +) -> i32 { let scored: Result<_, LocalFailure> = { // Scope the guard across startup, load, score and component teardown; // restore stdout before rendering on every success/failure path. @@ -1031,11 +1156,12 @@ fn run_local(options: &GlobalOptions, request: &contract::DecisionsRequest, json already_reported: false, })?; let started = Instant::now(); - let response = score_loaded(&loaded, request).map_err(|message| LocalFailure { - exit_code: 1, - message, - already_reported: false, - })?; + let response = + score_loaded_with(&loaded, request, controls).map_err(|message| LocalFailure { + exit_code: 1, + message, + already_reported: false, + })?; drop(loaded); Ok((response, started.elapsed().as_millis())) })() @@ -1106,6 +1232,13 @@ fn run(p: &crate::cli::Parsed, options: &GlobalOptions, json: bool) -> i32 { } } } + let controls = match LocalControls::from_flags(p) { + Ok(controls) => controls, + Err(error) => { + report_failure(json, &account::DecisionsResult::default(), &error); + return 2; + } + }; let mut request = match build_request(p) { Ok(request) => request, Err(error) => { @@ -1113,14 +1246,6 @@ fn run(p: &crate::cli::Parsed, options: &GlobalOptions, json: bool) -> i32 { return 2; } }; - if transport == Transport::Local && request.images.is_some() { - report_failure( - json, - &account::DecisionsResult::default(), - "images are scored on the cloud only: drop --local", - ); - return 2; - } if let Some(route) = forced_route { if let Err(error) = preferences::set_decisions_route(route.name()) { report_failure(json, &account::DecisionsResult::default(), &error); @@ -1210,14 +1335,6 @@ fn run(p: &crate::cli::Parsed, options: &GlobalOptions, json: bool) -> i32 { ); return 2; } - if request.images.is_some() { - report_failure( - json, - &account::DecisionsResult::default(), - "images are scored on the cloud only: drop --local, or add --cloud", - ); - return 2; - } // The local path promises no network; bootstrap must not run the // SDK's telemetry/auth phase, which posts anonymously to the staging // backend in development. @@ -1225,7 +1342,20 @@ fn run(p: &crate::cli::Parsed, options: &GlobalOptions, json: bool) -> i32 { offline: true, ..options.clone() }; - return run_local(&offline, &request, json); + return run_local(&offline, &request, &controls, json); + } + if controls.is_set() { + let set = LocalControls::FLAGS + .into_iter() + .filter(|name| p.is_set(name)) + .collect::>() + .join(", "); + report_failure( + json, + &account::DecisionsResult::default(), + &format!("{set} only apply to a local model: add --local"), + ); + return 2; } // The hosted console takes a model id, not a path; only the local // transport accepts a path, so the id-shape rule gates cloud here. @@ -1377,7 +1507,7 @@ pub fn register_decisions(app: &mut App) { cmd.add_option( "--image", ValueType::Text, - "PNG, JPEG, WebP or HEIC file every question is about, sent at full size (repeatable, up to 8; cloud only; HEIC needs macOS)", + "PNG, JPEG, WebP or HEIC file every question is about, sent at full size (repeatable, up to 8; HEIC needs macOS)", ) .multi(); cmd.add_option( @@ -1385,6 +1515,26 @@ pub fn register_decisions(app: &mut App) { ValueType::Float, "Probability temperature (>0, at most 100)", ); + cmd.add_option( + "--grouping", + ValueType::Text, + "Local only: 'joint' scores every question in one pass, 'per-question' one pass each (default: the model's)", + ); + cmd.add_option( + "--max-length", + ValueType::UInt64, + "Local only: token cap per pass; longer requests split their questions", + ); + cmd.add_option( + "--max-pixels", + ValueType::UInt64, + "Local only: pixel budget per image (default: the model's)", + ); + cmd.add_option( + "--min-pixels", + ValueType::UInt64, + "Local only: lower pixel bound per image (default: the model's)", + ); cmd.add_flag( "--json", "Print result/error JSON with request ID and HTTP status", @@ -1831,8 +1981,10 @@ mod tests { #[test] fn local_request_maps_cloud_shapes_onto_the_proto_contract() { - let proto = local_request(&ticket_request()); + let proto = local_request(&ticket_request(), &LocalControls::default()).unwrap(); assert_eq!(proto.state, "ticket"); + assert!(proto.options.is_none(), "no options when nothing is set"); + assert!(proto.images.is_empty()); assert_eq!(proto.questions.len(), 2); let refund = &proto.questions[0]; assert_eq!(refund.r#type, v1::DecisionQuestionType::Noul as i32); @@ -1847,6 +1999,67 @@ mod tests { assert_eq!(urgency.options[2].description, "high"); } + #[test] + fn local_request_carries_the_controls_and_the_image_bytes() { + let png = b"\x89PNG\r\n\x1a\nrest".to_vec(); + let request = contract::DecisionsRequest { + images: Some(vec![format!("data:image/png;base64,{}", base64(&png))]), + ..ticket_request() + }; + let controls = LocalControls { + grouping: v1::DecisionGrouping::PerQuestion, + max_length: 4096, + max_pixels: 1_638_400, + min_pixels: 65_536, + }; + let proto = local_request(&request, &controls).unwrap(); + let options = proto.options.expect("controls set the options"); + assert_eq!(options.grouping, v1::DecisionGrouping::PerQuestion as i32); + assert_eq!( + (options.max_length, options.max_pixels, options.min_pixels), + (4096, 1_638_400, 65_536) + ); + assert_eq!( + options.temperature, 0.0, + "an unset temperature stays the model's" + ); + assert_eq!(proto.images.len(), 1); + assert_eq!(proto.images[0].data, png); + assert_eq!(proto.images[0].mime_type, "image/png"); + } + + #[test] + fn image_data_urls_decode_back_to_their_bytes() { + for length in 0..7 { + let bytes: Vec = (0..length).map(|i| (i * 37 + 200) as u8).collect(); + let url = format!("data:image/webp;base64,{}", base64(&bytes)); + if bytes.is_empty() { + assert!( + decode_image_data_url(&url).is_err(), + "an empty image is refused" + ); + continue; + } + assert_eq!( + decode_image_data_url(&url).unwrap(), + (bytes, "image/webp".to_string()) + ); + } + } + + #[test] + fn malformed_image_data_urls_are_refused() { + for url in [ + "data:image/gif;base64,R0lG", + "data:image/png;base64,abc", + "data:image/png;base64,ab=c", + "data:image/png;base64,a===", + "data:image/png;base64,ab!d", + ] { + assert!(decode_image_data_url(url).is_err(), "{url}"); + } + } + #[test] fn local_response_maps_the_proto_result_back_to_the_cloud_shape() { let request = ticket_request(); diff --git a/tests/golden/expected/help__decide.stdout b/tests/golden/expected/help__decide.stdout index ad14e163..3262ebff 100644 --- a/tests/golden/expected/help__decide.stdout +++ b/tests/golden/expected/help__decide.stdout @@ -14,8 +14,17 @@ Options: --request TEXT Complete DecisionsRequest JSON file (- for stdin) --image TEXT ... PNG, JPEG, WebP or HEIC file every question is about, sent at full size (repeatable, up to 8; - cloud only; HEIC needs macOS) + HEIC needs macOS) --temperature FLOAT Probability temperature (>0, at most 100) + --grouping TEXT Local only: 'joint' scores every question in one + pass, 'per-question' one pass each (default: the + model's) + --max-length UINT Local only: token cap per pass; longer requests + split their questions + --max-pixels UINT Local only: pixel budget per image (default: the + model's) + --min-pixels UINT Local only: lower pixel bound per image (default: + the model's) --json Print result/error JSON with request ID and HTTP status --local Score on this machine with a local decision model diff --git a/tests/golden/expected/help__decisions.stdout b/tests/golden/expected/help__decisions.stdout index ad14e163..3262ebff 100644 --- a/tests/golden/expected/help__decisions.stdout +++ b/tests/golden/expected/help__decisions.stdout @@ -14,8 +14,17 @@ Options: --request TEXT Complete DecisionsRequest JSON file (- for stdin) --image TEXT ... PNG, JPEG, WebP or HEIC file every question is about, sent at full size (repeatable, up to 8; - cloud only; HEIC needs macOS) + HEIC needs macOS) --temperature FLOAT Probability temperature (>0, at most 100) + --grouping TEXT Local only: 'joint' scores every question in one + pass, 'per-question' one pass each (default: the + model's) + --max-length UINT Local only: token cap per pass; longer requests + split their questions + --max-pixels UINT Local only: pixel budget per image (default: the + model's) + --min-pixels UINT Local only: lower pixel bound per image (default: + the model's) --json Print result/error JSON with request ID and HTTP status --local Score on this machine with a local decision model diff --git a/tests/test_decisions_cli.py b/tests/test_decisions_cli.py index 2471eceb..12af44ee 100644 --- a/tests/test_decisions_cli.py +++ b/tests/test_decisions_cli.py @@ -420,7 +420,10 @@ def main(): gif.write_bytes(b"GIF89a") for args, message in ( (["--image", str(gif)], "not a PNG, JPEG, WebP or HEIC"), - (["--image", str(png), "--local"], "cloud only"), + (["--grouping", "joint"], "only apply to a local model"), + (["--grouping", "sideways"], "--grouping must be 'joint' or 'per-question'"), + (["--max-pixels", "10", "--min-pixels", "20"], "must not exceed --max-pixels"), + (["--max-length", "0"], "--max-length must be greater than 0"), (["--image", str(png)] * 9, "at most 8"), ): refused = invoke(binary, env, "decisions", "--input", "x", "--ask", "Okay?", *args) From fc6c28558384e11ec5accc9926d913a5a4165850 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sat, 17 Oct 2026 15:25:36 +0530 Subject: [PATCH 03/14] catalog: pplx-decider-4b (v1.2, text and images) from the runanywhere mirror --- src/catalog/catalog.rs | 100 ++++++++++++++++++ .../local__models__list__--all.stdout | 1 + .../local__models__list__--all__--json.stdout | 2 +- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/catalog/catalog.rs b/src/catalog/catalog.rs index cbd29db6..5ce9190d 100644 --- a/src/catalog/catalog.rs +++ b/src/catalog/catalog.rs @@ -2602,6 +2602,89 @@ const CLEF_27B_GGUF_FILES: &[CatalogFile] = &[CatalogFile { checksum_sha256: None, }]; +/// `perplexity-ai/pplx-decider-flash-v1.2` (BF16, Apache-2.0) mirrored at commit 6d771ad7b5ec into +/// runanywhere/decision-models-stable-fork, pinned to the upload commit. +/// Loads as is: no conversion, the readout and both protocol configs ride beside the shards. +const PPLX_DECIDER_4B_MLX_FILES: &[CatalogFile] = &[ + CatalogFile { + url: "https://huggingface.co/runanywhere/decision-models-stable-fork/resolve/bee2a09a0f7708ac40342b74a221a40e06fd870a/mlx/pplx-decider-4b/chat_template.jinja", + filename: "chat_template.jinja", + required: true, + size_bytes: 7756, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/runanywhere/decision-models-stable-fork/resolve/bee2a09a0f7708ac40342b74a221a40e06fd870a/mlx/pplx-decider-4b/config.json", + filename: "config.json", + required: true, + size_bytes: 2898, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/runanywhere/decision-models-stable-fork/resolve/bee2a09a0f7708ac40342b74a221a40e06fd870a/mlx/pplx-decider-4b/decision_config.json", + filename: "decision_config.json", + required: true, + size_bytes: 5435, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/runanywhere/decision-models-stable-fork/resolve/bee2a09a0f7708ac40342b74a221a40e06fd870a/mlx/pplx-decider-4b/joint_config.json", + filename: "joint_config.json", + required: true, + size_bytes: 248, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/runanywhere/decision-models-stable-fork/resolve/bee2a09a0f7708ac40342b74a221a40e06fd870a/mlx/pplx-decider-4b/model-00001-of-00002.safetensors", + filename: "model-00001-of-00002.safetensors", + required: true, + size_bytes: 4972946624, + checksum_sha256: Some("1c1a0c1ab331c7aec1b4b7932eabf80593b56c694fe2b779209c0e0413df1cef"), + }, + CatalogFile { + url: "https://huggingface.co/runanywhere/decision-models-stable-fork/resolve/bee2a09a0f7708ac40342b74a221a40e06fd870a/mlx/pplx-decider-4b/model-00002-of-00002.safetensors", + filename: "model-00002-of-00002.safetensors", + required: true, + size_bytes: 4105669472, + checksum_sha256: Some("21ba8301660e066f5849b60b1a36a242fc54ca18dd9bd526fdcbc50889171b70"), + }, + CatalogFile { + url: "https://huggingface.co/runanywhere/decision-models-stable-fork/resolve/bee2a09a0f7708ac40342b74a221a40e06fd870a/mlx/pplx-decider-4b/model.safetensors.index.json", + filename: "model.safetensors.index.json", + required: true, + size_bytes: 61898, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/runanywhere/decision-models-stable-fork/resolve/bee2a09a0f7708ac40342b74a221a40e06fd870a/mlx/pplx-decider-4b/processor_config.json", + filename: "processor_config.json", + required: true, + size_bytes: 1219, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/runanywhere/decision-models-stable-fork/resolve/bee2a09a0f7708ac40342b74a221a40e06fd870a/mlx/pplx-decider-4b/readout.safetensors", + filename: "readout.safetensors", + required: true, + size_bytes: 1305712, + checksum_sha256: Some("6751578171026190d4db8078e97162b404fd984646e0e9aec13844e4dd5b1711"), + }, + CatalogFile { + url: "https://huggingface.co/runanywhere/decision-models-stable-fork/resolve/bee2a09a0f7708ac40342b74a221a40e06fd870a/mlx/pplx-decider-4b/tokenizer.json", + filename: "tokenizer.json", + required: true, + size_bytes: 19989492, + checksum_sha256: Some("6f32ce20dc35f57a7f9ad1eac03525bd7d30f9df8cea6507e958279cc3657706"), + }, + CatalogFile { + url: "https://huggingface.co/runanywhere/decision-models-stable-fork/resolve/bee2a09a0f7708ac40342b74a221a40e06fd870a/mlx/pplx-decider-4b/tokenizer_config.json", + filename: "tokenizer_config.json", + required: true, + size_bytes: 1268, + checksum_sha256: None, + }, +]; + const PPLX_DECIDER_MLX_4BIT_FILES: &[CatalogFile] = &[ CatalogFile { url: "https://huggingface.co/mlx-community/pplx-decider-v1.1-27b-4bit/resolve/main/chat_template.jinja", @@ -4903,6 +4986,23 @@ const CATALOG: &[CatalogEntry] = &[ merge_key: Some("pplx-decider-27b"), harness_compatible: false, }, + CatalogEntry { + id: "mlx-pplx-decider-4b", + alias: Some("pplx-decider-4b"), + name: "Pplx Decider 4B v1.2 (decision, text + images)", + category: v1::ModelCategory::Decision, + framework: v1::InferenceFramework::Mlx, + format: v1::ModelFormat::Safetensors, + url: None, + files: PPLX_DECIDER_4B_MLX_FILES, + download_size_bytes: 9099992022, + context_length: 262144, + supports_thinking: false, + memory_required_bytes: 0, + cua_profile: "", + merge_key: Some("pplx-decider-4b"), + harness_compatible: false, + }, CatalogEntry { id: "lfm-d1-3b", alias: Some("lfm-d1-3b-gguf"), diff --git a/tests/golden/expected/local__models__list__--all.stdout b/tests/golden/expected/local__models__list__--all.stdout index ec8d383e..5318c1d4 100644 --- a/tests/golden/expected/local__models__list__--all.stdout +++ b/tests/golden/expected/local__models__list__--all.stdout @@ -32,6 +32,7 @@ nemotron-nano llm mlx 4.2 GB no mlx-llama3.2 llm mlx 679.6 MB no nemotron-mini llm mlx 2.2 GB no pplx-decider-27b decision mlx 14.3 GB no +pplx-decider-4b decision mlx 8.5 GB no qwen3-0.6b llm mlx/llama.cpp 639.0 MB no qwen3-4b-instruct-2507 llm mlx/llama.cpp 4.0 GB no [harness-compatible] qwen3.8-27b llm mlx/llama.cpp 15.9 GB no diff --git a/tests/golden/expected/local__models__list__--all__--json.stdout b/tests/golden/expected/local__models__list__--all__--json.stdout index 9a40968e..0a9d6511 100644 --- a/tests/golden/expected/local__models__list__--all__--json.stdout +++ b/tests/golden/expected/local__models__list__--all__--json.stdout @@ -1 +1 @@ -{"models":[{"id":"bonsai-1.7b","name":"Bonsai 1.7B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":248302272,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"bonsai-27b","name":"Bonsai 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3803452480,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"bonsai-4b","name":"Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":572270624,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"bonsai-8b","name":"Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1158654496,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"clef-27b","name":"Clef 27B (decision)","modality":"decision","backend":"mlx/llama.cpp","size_bytes":19232219200,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"clef-flash-9b","name":"Clef Flash 9B (decision)","modality":"decision","backend":"mlx/llama.cpp","size_bytes":6486448288,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"gemma-4-12b","name":"Gemma 4 12B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":7121861440,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"gemma-4-26b-a4b","name":"Gemma 4 26B-A4B (MoE)","modality":"llm","backend":"mlx/llama.cpp","size_bytes":17010980576,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"gemma-4-31b","name":"Gemma 4 31B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":18323733440,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"gemma-4-e2b","name":"Gemma 4 E2B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3106738272,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"gemma-4-e4b","name":"Gemma 4 E4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4977171584,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"gliner-2.5-decide","name":"GLiNER 2.5 Decide","modality":"decision","backend":"mlx/llama.cpp","size_bytes":299547616,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"granite-4.1-30b","name":"IBM Granite 4.1 30B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":17490241472,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"granite-4.1-3b","name":"IBM Granite 4.1 3B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":2099502400,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"granite-4.1-8b","name":"IBM Granite 4.1 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":5347915136,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"granite-4.2-30b","name":"IBM Granite 4.2 30B","modality":"llm","backend":"llama.cpp","size_bytes":18027118592,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"granite-4.2-8b","name":"IBM Granite 4.2 8B","modality":"llm","backend":"llama.cpp","size_bytes":5539627008,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"lfm-d1-3b","name":"LFM D1 3B","modality":"decision","backend":"mlx/llama.cpp","size_bytes":1674456672,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"lfm-d1-omni-600m","name":"LFM D1 Omni 600M","modality":"decision","backend":"mlx/llama.cpp","size_bytes":407207584,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"lfm2.5-1.2b","name":"LiquidAI LFM2.5 1.2B Instruct","modality":"llm","backend":"llama.cpp","size_bytes":730857472,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"lfm2.5-2.6b","name":"LiquidAI LFM2.5 2.6B","modality":"llm","backend":"llama.cpp","size_bytes":1674575872,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"lfm2.5-230m","name":"LiquidAI LFM2.5 230M","modality":"llm","backend":"llama.cpp","size_bytes":153406304,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"lfm2.5-350m","name":"LiquidAI LFM2.5 350M","modality":"llm","backend":"llama.cpp","size_bytes":229638144,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"llama-3.2-3b","name":"Llama 3.2 3B Instruct","modality":"llm","backend":"llama.cpp","size_bytes":2118123520,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"maple-preview","name":"DeepGrove Maple Preview","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4984016416,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"nemotron-nano","name":"NVIDIA Llama 3.1 Nemotron Nano 8B","modality":"llm","backend":"mlx","size_bytes":4534806075,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"mlx-llama3.2","name":"Llama 3.2 1B Instruct","modality":"llm","backend":"mlx","size_bytes":712575975,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"nemotron-mini","name":"NVIDIA Nemotron Mini 4B Instruct","modality":"llm","backend":"mlx","size_bytes":2392679103,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"pplx-decider-27b","name":"Pplx Decider 27B (decision)","modality":"decision","backend":"mlx","size_bytes":15362201309,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"qwen3-0.6b","name":"Qwen3 0.6B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":670040064,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"qwen3-4b-instruct-2507","name":"Qwen3 4B Instruct 2507","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4300000000,"downloaded":false,"harness_compatible":true,"local_path":"","cloud":false},{"id":"qwen3.8-27b","name":"Qwen3.8 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":17106775008,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"ternary-bonsai-1.7b","name":"Ternary-Bonsai 1.7B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":490163968,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"ternary-bonsai-27b","name":"Ternary-Bonsai 27B","modality":"llm","backend":"mlx","size_bytes":8490785104,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"ternary-bonsai-4b","name":"Ternary-Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1137806656,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"ternary-bonsai-8b","name":"Ternary-Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":2310125920,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"smollm2-135m","name":"SmolLM2 135M","modality":"llm","backend":"llama.cpp","size_bytes":100663296,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false}]} +{"models":[{"id":"bonsai-1.7b","name":"Bonsai 1.7B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":248302272,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"bonsai-27b","name":"Bonsai 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3803452480,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"bonsai-4b","name":"Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":572270624,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"bonsai-8b","name":"Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1158654496,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"clef-27b","name":"Clef 27B (decision)","modality":"decision","backend":"mlx/llama.cpp","size_bytes":19232219200,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"clef-flash-9b","name":"Clef Flash 9B (decision)","modality":"decision","backend":"mlx/llama.cpp","size_bytes":6486448288,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"gemma-4-12b","name":"Gemma 4 12B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":7121861440,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"gemma-4-26b-a4b","name":"Gemma 4 26B-A4B (MoE)","modality":"llm","backend":"mlx/llama.cpp","size_bytes":17010980576,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"gemma-4-31b","name":"Gemma 4 31B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":18323733440,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"gemma-4-e2b","name":"Gemma 4 E2B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3106738272,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"gemma-4-e4b","name":"Gemma 4 E4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4977171584,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"gliner-2.5-decide","name":"GLiNER 2.5 Decide","modality":"decision","backend":"mlx/llama.cpp","size_bytes":299547616,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"granite-4.1-30b","name":"IBM Granite 4.1 30B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":17490241472,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"granite-4.1-3b","name":"IBM Granite 4.1 3B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":2099502400,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"granite-4.1-8b","name":"IBM Granite 4.1 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":5347915136,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"granite-4.2-30b","name":"IBM Granite 4.2 30B","modality":"llm","backend":"llama.cpp","size_bytes":18027118592,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"granite-4.2-8b","name":"IBM Granite 4.2 8B","modality":"llm","backend":"llama.cpp","size_bytes":5539627008,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"lfm-d1-3b","name":"LFM D1 3B","modality":"decision","backend":"mlx/llama.cpp","size_bytes":1674456672,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"lfm-d1-omni-600m","name":"LFM D1 Omni 600M","modality":"decision","backend":"mlx/llama.cpp","size_bytes":407207584,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"lfm2.5-1.2b","name":"LiquidAI LFM2.5 1.2B Instruct","modality":"llm","backend":"llama.cpp","size_bytes":730857472,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"lfm2.5-2.6b","name":"LiquidAI LFM2.5 2.6B","modality":"llm","backend":"llama.cpp","size_bytes":1674575872,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"lfm2.5-230m","name":"LiquidAI LFM2.5 230M","modality":"llm","backend":"llama.cpp","size_bytes":153406304,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"lfm2.5-350m","name":"LiquidAI LFM2.5 350M","modality":"llm","backend":"llama.cpp","size_bytes":229638144,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"llama-3.2-3b","name":"Llama 3.2 3B Instruct","modality":"llm","backend":"llama.cpp","size_bytes":2118123520,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"maple-preview","name":"DeepGrove Maple Preview","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4984016416,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"nemotron-nano","name":"NVIDIA Llama 3.1 Nemotron Nano 8B","modality":"llm","backend":"mlx","size_bytes":4534806075,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"mlx-llama3.2","name":"Llama 3.2 1B Instruct","modality":"llm","backend":"mlx","size_bytes":712575975,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"nemotron-mini","name":"NVIDIA Nemotron Mini 4B Instruct","modality":"llm","backend":"mlx","size_bytes":2392679103,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"pplx-decider-27b","name":"Pplx Decider 27B (decision)","modality":"decision","backend":"mlx","size_bytes":15362201309,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"pplx-decider-4b","name":"Pplx Decider 4B v1.2 (decision, text + images)","modality":"decision","backend":"mlx","size_bytes":9099992022,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"qwen3-0.6b","name":"Qwen3 0.6B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":670040064,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"qwen3-4b-instruct-2507","name":"Qwen3 4B Instruct 2507","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4300000000,"downloaded":false,"harness_compatible":true,"local_path":"","cloud":false},{"id":"qwen3.8-27b","name":"Qwen3.8 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":17106775008,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"ternary-bonsai-1.7b","name":"Ternary-Bonsai 1.7B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":490163968,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"ternary-bonsai-27b","name":"Ternary-Bonsai 27B","modality":"llm","backend":"mlx","size_bytes":8490785104,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"ternary-bonsai-4b","name":"Ternary-Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1137806656,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"ternary-bonsai-8b","name":"Ternary-Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":2310125920,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false},{"id":"smollm2-135m","name":"SmolLM2 135M","modality":"llm","backend":"llama.cpp","size_bytes":100663296,"downloaded":false,"harness_compatible":false,"local_path":"","cloud":false}]} From 01a2a345bc1eb2e5e7f12c17908fd4fb7e9e1751 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sat, 17 Oct 2026 15:25:36 +0530 Subject: [PATCH 04/14] e2e: opt-in decision cases for text, images, controls and refusals --- scripts/test/e2e-modalities.sh | 50 ++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/scripts/test/e2e-modalities.sh b/scripts/test/e2e-modalities.sh index db54bb4f..456c252a 100755 --- a/scripts/test/e2e-modalities.sh +++ b/scripts/test/e2e-modalities.sh @@ -13,6 +13,11 @@ # 4. `wally models list --json` downloaded rows whose modality matches # 5. If WALLY_E2E_AUTO=1, small OSS catalog defaults the binary can run # +# Decisions are opt-in by id only (they are a verb, not a discoverable +# modality): WALLY_E2E_DECISION scores text; WALLY_E2E_DECISION_IMAGE names a +# decision model that takes images and also exercises the local controls. +# A text-only WALLY_E2E_DECISION must refuse an image request. +# # Skip = no model. Fail = a model was selected and the command failed. # Public CI leaves every knob unset and stays green. # @@ -458,5 +463,50 @@ else skipm "diarize (set WALLY_E2E_DIARIZE)" fi +# A refusal is the expected outcome: the command must fail, and say why. +expect_refusal() { + local label="$1" + local needle="$2" + shift 2 + set +e + "$@" >/dev/null 2>"${workdir}/err" + local rc=$? + set -e + if [[ "${rc}" -ne 0 ]] && grep -q "${needle}" "${workdir}/err"; then + ok "${label}" + else + bad "${label} (rc ${rc}; wanted a refusal naming '${needle}')" + fi +} + +decision_questions=(--input "A customer reports every API call returns 401 since a key rotation." + --ask "Is this an authentication problem?" --choice "Team?=support,billing,sales") + +if [[ -n "${WALLY_E2E_DECISION:-}" ]]; then + run_cmd "decision ${WALLY_E2E_DECISION}" \ + "${WALLY}" decisions --local -m "${WALLY_E2E_DECISION}" "${decision_questions[@]}" + if [[ -s "${png}" && "${WALLY_E2E_DECISION}" != "${WALLY_E2E_DECISION_IMAGE:-}" ]]; then + expect_refusal "decision ${WALLY_E2E_DECISION} refuses an image" "images" \ + "${WALLY}" decisions --local -m "${WALLY_E2E_DECISION}" --image "${png}" \ + "${decision_questions[@]}" + fi +else + skipm "decision (set WALLY_E2E_DECISION to a local decision model id)" +fi + +if [[ -n "${WALLY_E2E_DECISION_IMAGE:-}" && -s "${png}" ]]; then + run_cmd "decision+image ${WALLY_E2E_DECISION_IMAGE}" \ + "${WALLY}" decisions --local -m "${WALLY_E2E_DECISION_IMAGE}" --image "${png}" \ + --input "What colour is the picture?" --choice "Colour?=red,green,blue" + run_cmd "decision controls ${WALLY_E2E_DECISION_IMAGE}" \ + "${WALLY}" decisions --local -m "${WALLY_E2E_DECISION_IMAGE}" --image "${png}" \ + --grouping joint --max-pixels 200000 --max-length 8192 "${decision_questions[@]}" + expect_refusal "decision ${WALLY_E2E_DECISION_IMAGE} refuses an over-length question" "limit" \ + "${WALLY}" decisions --local -m "${WALLY_E2E_DECISION_IMAGE}" --max-length 16 \ + "${decision_questions[@]}" +else + skipm "decision+image (set WALLY_E2E_DECISION_IMAGE to a decision model that takes images)" +fi + echo "modalities: ${pass} passed, ${fail} failed, ${skip} skipped" exit "${fail}" From e19c3f100e4496eedf2bdd65676189d5a2229a93 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sat, 17 Oct 2026 15:31:16 +0530 Subject: [PATCH 05/14] swift: resolve mlx-swift-lm 3.31.11 and mlx-audio-swift 0.1.9 --- swift/Package.resolved | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/swift/Package.resolved b/swift/Package.resolved index a9bbd0a7..9179d638 100644 --- a/swift/Package.resolved +++ b/swift/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/RunanywhereAI/mlx-audio-swift.git", "state" : { - "revision" : "cade76c23fa00ef2184452adb12fae22942c73cb", - "version" : "0.1.8" + "revision" : "ca3c72af5ca246ad5f8764a15521b942cf0d3237", + "version" : "0.1.9" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/RunanywhereAI/mlx-swift-lm.git", "state" : { - "revision" : "4c31da1d4aa22e2d15b545120a0ba3c19368bc2c", - "version" : "3.31.10" + "revision" : "95ae16747bf0fdea1552343fe0ee24a53266c840", + "version" : "3.31.11" } }, { From 34f39f3c8677cb6a75727b40321ddbb502b74af3 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sat, 17 Oct 2026 16:17:14 +0530 Subject: [PATCH 06/14] sdk: build the Apple MLX host against the v0.20.40 Apple binaries --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- versions.toml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55c5ada0..ee7d54e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,7 @@ jobs: with: persist-credentials: false repository: RunanywhereAI/runanywhere-sdks - ref: c0cc8ebd416b8d4b675f2b6457fe3d4232cc093e + ref: 8e3d147f4dea7609d48ff5bb7c75edb234051853 path: .deps/runanywhere-sdks - name: Install ninja run: brew install ninja diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae67a72a..b28b6aa1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,7 +78,7 @@ jobs: with: persist-credentials: false repository: RunanywhereAI/runanywhere-sdks - ref: c0cc8ebd416b8d4b675f2b6457fe3d4232cc093e + ref: 8e3d147f4dea7609d48ff5bb7c75edb234051853 path: .deps/runanywhere-sdks - name: Install ninja run: brew install ninja diff --git a/versions.toml b/versions.toml index 4d702ff4..93c15b3b 100644 --- a/versions.toml +++ b/versions.toml @@ -33,14 +33,14 @@ kit_sha256_linux_arm64 = "22784d3e99752346e00917a92515ab5348d43093c9bbfe5e71954d # used by ci.yml/release.yml. Both move on their own cadence, distinct from the # C++ kit above. # -# sdk_ci_ref is the commit cpp-desktop-v0.20.40 was built from, not a tag -# name: release tags on +# sdk_ci_ref is the cpp-desktop-v0.20.40 source commit plus its Apple binary +# checksums (siddhesh/kit-0.20.40-swift), not a tag name: release tags on # that repo are not protected or immutable, and this checkout supplies the # Swift code compiled into the Apple bottle, so a retargeted tag would change # what ships. The SHA is what the tag resolved to when this pin was made. sdk_package_version = "0.20.37" tools_version = "6.0" -sdk_ci_ref = "c0cc8ebd416b8d4b675f2b6457fe3d4232cc093e" +sdk_ci_ref = "8e3d147f4dea7609d48ff5bb7c75edb234051853" [toolchain] # CMake's own floor; CMakeLists.txt declares these and check-versions holds it here. From f1912dbe607ae99495f73a830d924fc2a35950b0 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sat, 17 Oct 2026 17:15:11 +0530 Subject: [PATCH 07/14] models: run the downloaded build of a merged decision row instead of refusing --- src/commands/model_setup.rs | 145 ++++++++++++++++++++++++++++-------- 1 file changed, 112 insertions(+), 33 deletions(-) diff --git a/src/commands/model_setup.rs b/src/commands/model_setup.rs index bbade3b8..0857005c 100644 --- a/src/commands/model_setup.rs +++ b/src/commands/model_setup.rs @@ -9,7 +9,7 @@ use std::ffi::CString; use crate::bootstrap::GlobalOptions; -use crate::catalog::model_ref; +use crate::catalog::{self, model_ref}; use crate::harness; use crate::io::output::{error_line, status_line}; use crate::io::proto::{self, parse_proto_buffer, v1, ProtoBuffer}; @@ -87,6 +87,65 @@ pub fn refresh_registry() -> Result<(), String> { parse_proto_buffer::(out_buffer).map(|_| ()) } +/// The registry's record of `model_id`, if it has one. +fn registry_info(model_id: &str) -> Option { + let model_id_c = CString::new(model_id).ok()?; + let mut info_out = ProtoBuffer::new(); + // SAFETY: model_id_c is a valid, NUL-terminated C string kept alive for + // the duration of this call; info_out is live/initialized. + let get_rc = unsafe { + sys::rac_model_registry_get_proto_buffer( + sys::rac_get_model_registry(), + model_id_c.as_ptr(), + info_out.as_mut_ptr(), + ) + }; + // Parsed unconditionally: it interprets the {status,error_message} + // envelope and frees the buffer on every path (no leak on get failure). + let info = parse_proto_buffer::(info_out).ok()?; + (get_rc == sys::SUCCESS).then_some(info) +} + +/// Commons reconciliation (the refresh before any lookup) already validated +/// folder completeness; registry_status is the single authority. +fn is_downloaded(info: &v1::ModelInfo) -> bool { + info.registry_status + .and_then(|status| v1::ModelRegistryStatus::try_from(status).ok()) + == Some(v1::ModelRegistryStatus::Downloaded) +} + +/// Another build of the same `models list` row that is already on disk, used +/// when the one `reference` names is not. `models list` shows a merged row as +/// downloaded when any of its builds is, so `-m ` must be able to run +/// that build. Limited to decision models and offline callers, which would +/// otherwise refuse outright; everything else keeps pulling the build it named. +fn downloaded_sibling( + reference: &str, + model_id: &str, + offline: bool, +) -> Option<(String, v1::ModelInfo)> { + sibling_candidates(reference, model_id, offline) + .into_iter() + .find_map(|id| { + let info = registry_info(id)?; + is_downloaded(&info).then(|| (id.to_string(), info)) + }) +} + +/// The other builds of `model_id`'s merged row that may stand in for it. +fn sibling_candidates(reference: &str, model_id: &str, offline: bool) -> Vec<&'static str> { + let named = catalog::find(reference).or_else(|| catalog::find(model_id)); + if !offline && named.map(|entry| entry.category) != Some(v1::ModelCategory::Decision) { + return Vec::new(); + } + let key = catalog::merge_key_for(model_id); + catalog::all() + .iter() + .filter(|entry| entry.id != model_id && entry.merge_key.unwrap_or(entry.id) == key) + .map(|entry| entry.id) + .collect() +} + /// Resolve ref -> ensure downloaded (auto-pull with progress) -> resolve /// paths. Err is the exit code the command should return (0 is never an Err /// variant; errors are already printed to stderr before returning). @@ -101,45 +160,31 @@ pub fn ensure_model_ready( return Err(1); } }; - let mut out = ResolvedModelPaths { - model_id: resolved.model_id.clone(), - ..Default::default() - }; - // Link any on-disk artifacts before deciding whether to pull. if let Err(error) = refresh_registry() { status_line(&format!("warning: registry refresh failed: {error}")); } - // Display name (best effort) + downloaded check. - let mut downloaded = false; - if let Ok(model_id_c) = CString::new(resolved.model_id.as_str()) { - let mut info_out = ProtoBuffer::new(); - // SAFETY: model_id_c is a valid, NUL-terminated C string kept alive - // for the duration of this call; info_out is live/initialized. - let get_rc = unsafe { - sys::rac_model_registry_get_proto_buffer( - sys::rac_get_model_registry(), - model_id_c.as_ptr(), - info_out.as_mut_ptr(), - ) - }; - // Parsed unconditionally: it interprets the {status,error_message} - // envelope and frees the buffer on every path (no leak on get - // failure). - if let Ok(info) = parse_proto_buffer::(info_out) { - if get_rc == sys::SUCCESS { - out.display_name = info.name; - // Commons reconciliation (refresh above) already validated - // folder completeness; registry_status is the single - // authority. - downloaded = info - .registry_status - .and_then(|status| v1::ModelRegistryStatus::try_from(status).ok()) - == Some(v1::ModelRegistryStatus::Downloaded); - } + let mut model_id = resolved.model_id.clone(); + let mut registered = registry_info(&model_id); + let mut downloaded = registered.as_ref().is_some_and(is_downloaded); + if !downloaded { + if let Some((sibling, info)) = downloaded_sibling(reference, &model_id, options.offline) { + status_line(&format!("using the downloaded {sibling} for {reference}")); + model_id = sibling; + registered = Some(info); + downloaded = true; } } + let mut out = ResolvedModelPaths { + model_id: model_id.clone(), + display_name: registered.map(|info| info.name).unwrap_or_default(), + ..Default::default() + }; + let resolved = model_ref::Resolved { + model_id, + ..resolved + }; if !downloaded { // Offline callers (`wally decisions --local`) promise no network @@ -197,3 +242,37 @@ pub fn ensure_model_ready( } } } + +#[cfg(test)] +mod tests { + use super::sibling_candidates; + + #[test] + fn a_decision_row_offers_its_other_build() { + assert_eq!( + sibling_candidates("clef-flash-9b", "clef-flash-9b", false), + ["mlx-clef-flash-9b-4bit"] + ); + } + + #[test] + fn a_chat_row_keeps_the_build_it_named_when_online() { + assert!( + sibling_candidates("qwen3-4b-instruct-2507", "qwen3-4b-instruct-2507", false) + .is_empty() + ); + } + + #[test] + fn an_offline_caller_may_use_any_build_of_the_row() { + assert_eq!( + sibling_candidates("qwen3-4b-instruct-2507", "qwen3-4b-instruct-2507", true), + ["mlx-qwen3-4b-instruct-2507-4bit"] + ); + } + + #[test] + fn a_model_outside_any_row_has_no_siblings() { + assert!(sibling_candidates("silero-vad", "silero-vad", true).is_empty()); + } +} From 4776c8c1d910186a12741ae98574c158eecd6cec Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sat, 17 Oct 2026 17:45:23 +0530 Subject: [PATCH 08/14] harness: drop null schema fields so MCP tools no longer break MLX chat templates --- src/anthropic/translate.rs | 5 ++- src/codex/translate.rs | 5 ++- src/util/mod.rs | 1 + src/util/tool_schema.rs | 67 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 src/util/tool_schema.rs diff --git a/src/anthropic/translate.rs b/src/anthropic/translate.rs index aae943b4..a150026c 100644 --- a/src/anthropic/translate.rs +++ b/src/anthropic/translate.rs @@ -481,7 +481,10 @@ fn tools_to_openai(tools: &Value) -> Value { } let mut function = serde_json::Map::new(); function.insert("name".into(), json!(field(tool, "name"))); - function.insert("parameters".into(), tool["input_schema"].clone()); + function.insert( + "parameters".into(), + crate::util::tool_schema::without_null_fields(&tool["input_schema"]), + ); if let Some(desc) = tool.get("description").filter(|d| d.is_string()) { function.insert("description".into(), desc.clone()); } diff --git a/src/codex/translate.rs b/src/codex/translate.rs index 1f6a4aee..62f85705 100644 --- a/src/codex/translate.rs +++ b/src/codex/translate.rs @@ -100,7 +100,10 @@ fn function_tool(name: &str, description: Option<&Value>, parameters: Value) -> if let Some(description) = description.filter(|d| d.is_string()) { function.insert("description".into(), description.clone()); } - function.insert("parameters".into(), parameters); + function.insert( + "parameters".into(), + crate::util::tool_schema::without_null_fields(¶meters), + ); json!({"type": "function", "function": Value::Object(function)}) } diff --git a/src/util/mod.rs b/src/util/mod.rs index 796dcf35..9870612d 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -4,6 +4,7 @@ pub mod hush; pub mod interrupt; pub mod junction; pub mod term; +pub mod tool_schema; /// Read an environment variable as UTF-8. Returns `None` when unset or empty — /// the C++ code treated an empty value as unset throughout. diff --git a/src/util/tool_schema.rs b/src/util/tool_schema.rs new file mode 100644 index 00000000..be48ef07 --- /dev/null +++ b/src/util/tool_schema.rs @@ -0,0 +1,67 @@ +//! Tool-schema cleanup shared by the Anthropic and Responses translators. + +use serde_json::Value; + +/// `schema` with every null-valued object field removed, at any depth. +/// +/// MCP servers commonly declare optional parameters as `"default": null`. The +/// MLX engine renders tools through a Jinja chat template that cannot take a +/// JSON null and fails the whole request, so Claude Code with such a tool +/// could not reach an MLX model. A null field says nothing a schema reader +/// needs, so dropping it changes no tool's meaning. +pub fn without_null_fields(schema: &Value) -> Value { + match schema { + Value::Object(map) => Value::Object( + map.iter() + .filter(|(_, value)| !value.is_null()) + .map(|(key, value)| (key.clone(), without_null_fields(value))) + .collect(), + ), + Value::Array(items) => Value::Array(items.iter().map(without_null_fields).collect()), + other => other.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::without_null_fields; + use serde_json::json; + + #[test] + fn null_defaults_are_dropped_at_any_depth() { + let schema = json!({ + "type": "object", + "properties": { + "image": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null}, + "nested": {"type": "object", "properties": {"x": {"default": null, "type": "integer"}}} + }, + "required": [] + }); + assert_eq!( + without_null_fields(&schema), + json!({ + "type": "object", + "properties": { + "image": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "nested": {"type": "object", "properties": {"x": {"type": "integer"}}} + }, + "required": [] + }) + ); + } + + #[test] + fn a_schema_without_nulls_is_unchanged() { + let schema = + json!({"type": "object", "properties": {"p": {"type": "string", "default": "a"}}}); + assert_eq!(without_null_fields(&schema), schema); + } + + #[test] + fn nulls_inside_arrays_of_objects_are_dropped_but_array_nulls_stay() { + assert_eq!( + without_null_fields(&json!({"enum": [null, "a"], "items": [{"default": null}]})), + json!({"enum": [null, "a"], "items": [{}]}) + ); + } +} From 8da045bfa8cbfebc763f1c39af8c6edeba6e1ff3 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sat, 17 Oct 2026 17:55:21 +0530 Subject: [PATCH 09/14] models: remove a listed model whose catalog entry is gone --- src/commands/cmd_rm.rs | 41 +++++++++++++++++++++++++++++++++---- src/commands/model_setup.rs | 2 +- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/commands/cmd_rm.rs b/src/commands/cmd_rm.rs index 7b65c5b7..cb2f4b11 100644 --- a/src/commands/cmd_rm.rs +++ b/src/commands/cmd_rm.rs @@ -113,6 +113,22 @@ fn registry_unregister_warning( } } +/// The registry ids `models list` may print as `name`: the id itself, or the +/// id with the `mlx-` prefix the list drops. The registry decides; the +/// catalog is not consulted, since the model is one it no longer names. +fn listed_name_candidates(name: &str) -> [String; 2] { + [name.to_string(), format!("mlx-{name}")] +} + +fn registered_by_listed_name(name: &str) -> Option { + if refresh_registry().is_err() { + return None; + } + listed_name_candidates(name) + .into_iter() + .find(|id| crate::commands::model_setup::registry_info(id).is_some()) +} + fn run_rm(options: &GlobalOptions, reference: &str, force: bool) -> i32 { let Ok(env) = bootstrap(options) else { return 1; @@ -120,10 +136,19 @@ fn run_rm(options: &GlobalOptions, reference: &str, force: bool) -> i32 { let resolved = match model_ref::resolve(reference, None) { Ok(resolved) => resolved, - Err((_, error)) => { - out::error_line(&error); - return 1; - } + // A model downloaded under a catalog id that a later release renamed + // or dropped still shows in `models list`; it must stay removable by + // the name the list prints. + Err((_, error)) => match registered_by_listed_name(reference) { + Some(model_id) => model_ref::Resolved { + model_id, + from_catalog: false, + }, + None => { + out::error_line(&error); + return 1; + } + }, }; // C++ reuses a single `std::string error` across resolve/refresh/get/ @@ -289,6 +314,14 @@ pub fn configure_models_delete(cmd: &mut App) { #[cfg(test)] mod tests { + #[test] + fn a_listed_name_maps_to_itself_and_its_mlx_build() { + assert_eq!( + super::listed_name_candidates("pplx-decider-flash-v1.2"), + ["pplx-decider-flash-v1.2", "mlx-pplx-decider-flash-v1.2"] + ); + } + use super::*; // local_path pointing straight at a shared framework directory (its diff --git a/src/commands/model_setup.rs b/src/commands/model_setup.rs index 0857005c..cf84e1f9 100644 --- a/src/commands/model_setup.rs +++ b/src/commands/model_setup.rs @@ -88,7 +88,7 @@ pub fn refresh_registry() -> Result<(), String> { } /// The registry's record of `model_id`, if it has one. -fn registry_info(model_id: &str) -> Option { +pub(crate) fn registry_info(model_id: &str) -> Option { let model_id_c = CString::new(model_id).ok()?; let mut info_out = ProtoBuffer::new(); // SAFETY: model_id_c is a valid, NUL-terminated C string kept alive for From fd71ea3057021f6462fffb620514e93deadb8780 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sat, 17 Oct 2026 17:58:54 +0530 Subject: [PATCH 10/14] models: expect no MLX sibling where the catalog hides MLX builds --- src/commands/model_setup.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/commands/model_setup.rs b/src/commands/model_setup.rs index cf84e1f9..2549205f 100644 --- a/src/commands/model_setup.rs +++ b/src/commands/model_setup.rs @@ -247,11 +247,21 @@ pub fn ensure_model_ready( mod tests { use super::sibling_candidates; + /// The MLX builds these rows pair with are Apple-only; elsewhere the + /// catalog hides them, so there is no other build to stand in. + fn mlx_build(id: &'static str) -> Vec<&'static str> { + if cfg!(target_os = "macos") { + vec![id] + } else { + Vec::new() + } + } + #[test] fn a_decision_row_offers_its_other_build() { assert_eq!( sibling_candidates("clef-flash-9b", "clef-flash-9b", false), - ["mlx-clef-flash-9b-4bit"] + mlx_build("mlx-clef-flash-9b-4bit") ); } @@ -267,7 +277,7 @@ mod tests { fn an_offline_caller_may_use_any_build_of_the_row() { assert_eq!( sibling_candidates("qwen3-4b-instruct-2507", "qwen3-4b-instruct-2507", true), - ["mlx-qwen3-4b-instruct-2507-4bit"] + mlx_build("mlx-qwen3-4b-instruct-2507-4bit") ); } From 17126d90410c686f8c461b82a599feec397adb14 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sat, 17 Oct 2026 18:07:44 +0530 Subject: [PATCH 11/14] sdk: windows-arm64 kit 0.20.40 now carries llama.cpp --- versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/versions.toml b/versions.toml index e8ae1e2d..19e8a7e2 100644 --- a/versions.toml +++ b/versions.toml @@ -23,7 +23,7 @@ idl_schema_sha256 = "859d5ea75645ca0268e1348a9968813937ea5be0e35340768b0c07786e0 idl_protoc_version = "35.1" kit_sha256_macos_arm64 = "e5a3f10d2ab6023e3d7c599c2a4ca2d05a083d294ac11f866910de0d581bb2f7" kit_sha256_windows_x64 = "05720991852c948ce8c45462e025ab410d05fdce852ce1e5410e5c1b70cc0312" -kit_sha256_windows_arm64 = "ee9c1461701fe27ae409b5f91075e48343a0cc26ed02321b963a4da999e5680a" +kit_sha256_windows_arm64 = "8b59c3c090aae811f5206e60406a0bd5c3407c05ad71dc272c437a4232cd2157" kit_sha256_linux_x64 = "a68b5943315780b4ce845d13966ef592f76595bf2fe9d21cc76e89385df7f35b" kit_sha256_linux_arm64 = "22784d3e99752346e00917a92515ab5348d43093c9bbfe5e71954d7fb9a8d70b" From ce5fdd2a3efc74b669a7d7a42d3dedcd8e4b6197 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sat, 17 Oct 2026 18:22:52 +0530 Subject: [PATCH 12/14] release: 0.8.12 --- Cargo.lock | 2 +- Cargo.toml | 2 +- Formula/wally.rb | 6 +++--- versions.toml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ade54f7d..3ad62909 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1410,7 +1410,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wally" -version = "0.8.11" +version = "0.8.12" dependencies = [ "brotli-decompressor", "ctrlc", diff --git a/Cargo.toml b/Cargo.toml index 58254f4b..431c986d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ [package] name = "wally" # Must equal [product] version in versions.toml (scripts/ci/check-versions.py). -version = "0.8.11" +version = "0.8.12" edition = "2021" rust-version = "1.93" license = "Apache-2.0" diff --git a/Formula/wally.rb b/Formula/wally.rb index 243b2c8f..c4f5199a 100644 --- a/Formula/wally.rb +++ b/Formula/wally.rb @@ -3,15 +3,15 @@ class Wally < Formula desc "Run language, speech and image models on your own machine" homepage "https://github.com/RunanywhereAI/wally" - version "0.8.11" + version "0.8.12" license "MIT" # macOS arm64 ships the Swift MLX host # (llama.cpp + ONNX + Sherpa + MLX). Linux is not in this cut. on_macos do on_arm do - url "https://github.com/RunanywhereAI/wally/releases/download/v0.8.11/wally-0.8.11-macos-arm64.tar.gz" - # Placeholder -- the v0.8.11 release has not published a wally-named asset + url "https://github.com/RunanywhereAI/wally/releases/download/v0.8.12/wally-0.8.12-macos-arm64.tar.gz" + # Placeholder -- the v0.8.12 release has not published a wally-named asset # yet. scripts/release/update-tap.sh re-stamps this from the real release # checksum; a stale value here fails brew install's own hash check # rather than installing something unverified. diff --git a/versions.toml b/versions.toml index 19e8a7e2..b71ca56f 100644 --- a/versions.toml +++ b/versions.toml @@ -7,7 +7,7 @@ [product] # wally's own release version; git tag is v (auto-tag.yml reads it here). -version = "0.8.11" +version = "0.8.12" [contracts] # Vendored from InferenceInfra at this exact revision. From e949e5abba17952fb936ae7bedcfe7af0db98ecc Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sat, 17 Oct 2026 18:23:57 +0530 Subject: [PATCH 13/14] formula: describe wally as local and account models --- Formula/wally.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Formula/wally.rb b/Formula/wally.rb index c4f5199a..ff77cb19 100644 --- a/Formula/wally.rb +++ b/Formula/wally.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Wally < Formula - desc "Run language, speech and image models on your own machine" + desc "Run models on your machine or your RunAnywhere account" homepage "https://github.com/RunanywhereAI/wally" version "0.8.12" license "MIT" From 073145b377252cbe2f9f043edd05a3e7fac191e8 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sat, 17 Oct 2026 18:24:10 +0530 Subject: [PATCH 14/14] formula: same description for the beta formula --- Formula/wally-beta.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Formula/wally-beta.rb b/Formula/wally-beta.rb index 55d0b8d1..1f1ae2a9 100644 --- a/Formula/wally-beta.rb +++ b/Formula/wally-beta.rb @@ -1,5 +1,5 @@ class WallyBeta < Formula - desc "Run language, speech and image models on your own machine (beta)" + desc "Run models on your machine or your RunAnywhere account (beta)" homepage "https://github.com/RunanywhereAI/wally" url "https://github.com/RunanywhereAI/wally/releases/download/v0.4.0-beta.1/wally-0.4.0-beta.1-Darwin-arm64.tar.gz" sha256 "0000000000000000000000000000000000000000000000000000000000000000"