From 8a5b2cdf2ba2590fed87d13ee60072d7e9e8cb43 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 00:17:16 +0530 Subject: [PATCH 01/38] sdk: bump kit pin to cpp-desktop-v0.20.38-2 (ABI v13, IDL 1.5.0) Regenerated src/sys/bindings.rs from the 0.20.38-2 kit headers: adds the rac_decision_* C ABI (124 declarations) and renames the engine vtable's reserved_slot_5 to decision_ops, mirrored in the local fake_llm fixture. --- src/sys/bindings.rs | 290 ++++++++++++++++++++++++++++++-- tests/local_harness/fake_llm.rs | 2 +- versions.toml | 16 +- 3 files changed, 287 insertions(+), 21 deletions(-) diff --git a/src/sys/bindings.rs b/src/sys/bindings.rs index 2222e4fb..58a5cac2 100644 --- a/src/sys/bindings.rs +++ b/src/sys/bindings.rs @@ -85,6 +85,16 @@ pub type rac_llamacpp_log_callback_fn = ::std::option::Option< user_data: *mut ::std::os::raw::c_void, ), >; +#[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 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; +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; +pub type rac_decision_service_ops_t = rac_decision_service_ops; +pub type rac_decision_service_t = rac_decision_service; pub type rac_diarization_options_t = rac_diarization_options; pub type rac_diarization_segment_t = rac_diarization_segment; pub type rac_diarization_result_t = rac_diarization_result; @@ -311,6 +321,17 @@ pub type rac_mlx_context_length_fn = ::std::option::Option< user_data: *mut ::std::os::raw::c_void, ) -> rac_result_t, >; +pub type rac_mlx_decision_fn = ::std::option::Option< + unsafe extern "C" fn( + handle: rac_handle_t, + state: *const ::std::os::raw::c_char, + questions: *const rac_decision_question_t, + question_count: usize, + options: *const rac_decision_options_t, + out_result: *mut rac_decision_result_t, + user_data: *mut ::std::os::raw::c_void, + ) -> rac_result_t, +>; pub type rac_mlx_vlm_process_fn = ::std::option::Option< unsafe extern "C" fn( handle: rac_handle_t, @@ -1186,6 +1207,106 @@ pub struct rac_llm_llamacpp_config { #[doc = " Batch size for prompt processing"] pub batch_size: i32, } +#[doc = " One candidate answer for a question. Both pointers stay caller-owned for the\n duration of the decide call."] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct rac_decision_option { + #[doc = " Stable identifier echoed back in the answer."] + pub key: *const ::std::os::raw::c_char, + #[doc = " Human-readable text for this option. MAY be NULL."] + pub description: *const ::std::os::raw::c_char, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct rac_decision_question { + #[doc = " Identifier echoed back on the matching answer."] + pub id: *const ::std::os::raw::c_char, + pub type_: rac_decision_question_type_t, + #[doc = " Instructions read by the model. MAY be NULL."] + pub instructions: *const ::std::os::raw::c_char, + pub options: *const rac_decision_option_t, + pub option_count: usize, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct rac_decision_options { + #[doc = " Per-request temperature override; 0 = the model's own per-type value."] + 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, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct rac_decision_answer { + #[doc = " malloc-owned copy of the question id."] + pub id: *mut ::std::os::raw::c_char, + pub type_: rac_decision_question_type_t, + #[doc = " CHOICE: malloc-owned copy of the winning option key (NULL otherwise)."] + pub choice: *mut ::std::os::raw::c_char, + #[doc = " NOUL: probability of the \"true\" option in [0, 1]."] + pub noul: f32, + #[doc = " SCORE: expected level index over the ordered options."] + pub score: f32, + #[doc = " Probability of each option, parallel to the request question's options."] + pub probabilities: *mut f32, + pub probability_count: usize, + #[doc = " Calibrated confidence in [0, 1]."] + pub confidence: f32, + #[doc = " SCORE only: descriptions parallel to `probabilities` (entries may be\n NULL). NULL when the request carried no descriptions. The array is\n malloc-owned and released by rac_decision_result_free; the entries\n point into the caller's request (which must outlive the result), so a\n backend must NOT allocate them and MUST NOT free them."] + pub legend: *mut *mut ::std::os::raw::c_char, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct rac_decision_result { + #[doc = " One answer per request question, in request order."] + pub answers: *mut rac_decision_answer_t, + pub answer_count: usize, + pub processing_time_ms: i64, + pub model_id: *mut ::std::os::raw::c_char, + #[doc = " Tokens of the jointly-evaluated prompt."] + pub input_tokens: i32, + #[doc = " Prompt-wording version the model served; 0 when it reports none."] + pub prompt_format_version: u32, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct rac_decision_service_ops { + pub initialize: ::std::option::Option< + unsafe extern "C" fn( + impl_: *mut ::std::os::raw::c_void, + model_path: *const ::std::os::raw::c_char, + ) -> rac_result_t, + >, + #[doc = " Score every question against the state and produce one answer per\n question, in request order. Every pointer returned in out_result MUST use\n a malloc/free-compatible allocator and remains caller-owned on both\n success and partial failure."] + pub decide: ::std::option::Option< + unsafe extern "C" fn( + impl_: *mut ::std::os::raw::c_void, + state: *const ::std::os::raw::c_char, + questions: *const rac_decision_question_t, + question_count: usize, + options: *const rac_decision_options_t, + out_result: *mut rac_decision_result_t, + ) -> rac_result_t, + >, + pub cleanup: ::std::option::Option< + unsafe extern "C" fn(impl_: *mut ::std::os::raw::c_void) -> rac_result_t, + >, + pub destroy: ::std::option::Option, + pub create: ::std::option::Option< + unsafe extern "C" fn( + model_id: *const ::std::os::raw::c_char, + config_json: *const ::std::os::raw::c_char, + out_impl: *mut *mut ::std::os::raw::c_void, + ) -> rac_result_t, + >, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct rac_decision_service { + pub ops: *const rac_decision_service_ops_t, + pub impl_: *mut ::std::os::raw::c_void, + pub model_id: *const ::std::os::raw::c_char, +} #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct rac_diarization_options { @@ -2134,6 +2255,8 @@ pub struct rac_mlx_callbacks { pub llm_generate_chat_stream: rac_mlx_llm_generate_chat_stream_fn, #[doc = " Loaded model context from Swift's parsed config.json (optional)."] pub context_length: rac_mlx_context_length_fn, + #[doc = " Joint decision scoring (optional). Runs one forward pass over the encoded\n request and fills `out_result` with one answer per question, in request\n order. Appended for ABI compatibility; rac_mlx_set_callbacks probes\n struct_size before reading this slot."] + pub decision: rac_mlx_decision_fn, } #[repr(C)] #[derive(Debug, Copy, Clone)] @@ -3592,7 +3715,8 @@ pub struct rac_engine_vtable { pub image_embedding_ops: *const rac_image_embedding_service_ops, #[doc = " OCR (`RAC_PRIMITIVE_OCR`). Promoted from reserved_slot_4 in ABI v11 — same binary\n offset, so the 17-pointer tail is unchanged and only the ABI version gates it."] pub ocr_ops: *mut rac_ocr_service_ops, - pub reserved_slot_5: *const ::std::os::raw::c_void, + #[doc = " Joint decision scoring (`RAC_PRIMITIVE_DECIDE`). Promoted from reserved_slot_5 in\n ABI v13 — same binary offset, so the 17-pointer tail is unchanged."] + pub decision_ops: *const rac_decision_service_ops, pub reserved_slot_6: *const ::std::os::raw::c_void, pub reserved_slot_7: *const ::std::os::raw::c_void, pub reserved_slot_8: *const ::std::os::raw::c_void, @@ -3797,6 +3921,8 @@ pub const RAC_DEFAULT_LLM_GENERATION_OPTIONS_PRESENCE_PENALTY: f64 = 0.0; pub const RAC_DEFAULT_LLM_GENERATION_OPTIONS_REPEAT_LAST_N: u32 = 0; 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_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; @@ -3902,6 +4028,7 @@ pub const RAC_MLX_SESSION_KIND_LLM: u32 = 4; pub const RAC_MLX_SESSION_KIND_VLM: u32 = 5; pub const RAC_MLX_SESSION_KIND_EMBEDDINGS: u32 = 8; pub const RAC_MLX_SESSION_KIND_DIARIZATION: u32 = 11; +pub const RAC_MLX_SESSION_KIND_DECISION: u32 = 15; pub const RAC_MLX_CALLBACKS_LEGACY_SIZE: u32 = 192; pub const RAC_AUDIO_LEVEL_FLOOR_DB: f64 = -60.0; pub const RAC_AUDIO_PCM16_SCALE: f64 = 32768.0; @@ -3943,7 +4070,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 = 12; +pub const RAC_PLUGIN_API_VERSION: u32 = 13; 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; @@ -4224,6 +4351,115 @@ const _: () = { ["Offset of field: rac_llm_llamacpp_config::batch_size"] [::std::mem::offset_of!(rac_llm_llamacpp_config, batch_size) - 12usize]; }; +pub const RAC_DECISION_QUESTION_UNSPECIFIED: rac_decision_question_type = 0; +#[doc = " Pick one of the listed options; the answer carries per-option probabilities."] +pub const RAC_DECISION_QUESTION_CHOICE: rac_decision_question_type = 1; +#[doc = " Yes / no / unknown; the answer carries the probability of the \"true\" option."] +pub const RAC_DECISION_QUESTION_NOUL: rac_decision_question_type = 2; +#[doc = " A level on an ordered scale; the answer carries the expected level index."] +pub const RAC_DECISION_QUESTION_SCORE: rac_decision_question_type = 3; +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of rac_decision_option"][::std::mem::size_of::() - 16usize]; + ["Alignment of rac_decision_option"][::std::mem::align_of::() - 8usize]; + ["Offset of field: rac_decision_option::key"] + [::std::mem::offset_of!(rac_decision_option, key) - 0usize]; + ["Offset of field: rac_decision_option::description"] + [::std::mem::offset_of!(rac_decision_option, description) - 8usize]; +}; +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of rac_decision_question"][::std::mem::size_of::() - 40usize]; + ["Alignment of rac_decision_question"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: rac_decision_question::id"] + [::std::mem::offset_of!(rac_decision_question, id) - 0usize]; + ["Offset of field: rac_decision_question::type_"] + [::std::mem::offset_of!(rac_decision_question, type_) - 8usize]; + ["Offset of field: rac_decision_question::instructions"] + [::std::mem::offset_of!(rac_decision_question, instructions) - 16usize]; + ["Offset of field: rac_decision_question::options"] + [::std::mem::offset_of!(rac_decision_question, options) - 24usize]; + ["Offset of field: rac_decision_question::option_count"] + [::std::mem::offset_of!(rac_decision_question, option_count) - 32usize]; +}; +#[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]; + ["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]; +}; +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of rac_decision_answer"][::std::mem::size_of::() - 64usize]; + ["Alignment of rac_decision_answer"][::std::mem::align_of::() - 8usize]; + ["Offset of field: rac_decision_answer::id"] + [::std::mem::offset_of!(rac_decision_answer, id) - 0usize]; + ["Offset of field: rac_decision_answer::type_"] + [::std::mem::offset_of!(rac_decision_answer, type_) - 8usize]; + ["Offset of field: rac_decision_answer::choice"] + [::std::mem::offset_of!(rac_decision_answer, choice) - 16usize]; + ["Offset of field: rac_decision_answer::noul"] + [::std::mem::offset_of!(rac_decision_answer, noul) - 24usize]; + ["Offset of field: rac_decision_answer::score"] + [::std::mem::offset_of!(rac_decision_answer, score) - 28usize]; + ["Offset of field: rac_decision_answer::probabilities"] + [::std::mem::offset_of!(rac_decision_answer, probabilities) - 32usize]; + ["Offset of field: rac_decision_answer::probability_count"] + [::std::mem::offset_of!(rac_decision_answer, probability_count) - 40usize]; + ["Offset of field: rac_decision_answer::confidence"] + [::std::mem::offset_of!(rac_decision_answer, confidence) - 48usize]; + ["Offset of field: rac_decision_answer::legend"] + [::std::mem::offset_of!(rac_decision_answer, legend) - 56usize]; +}; +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of rac_decision_result"][::std::mem::size_of::() - 40usize]; + ["Alignment of rac_decision_result"][::std::mem::align_of::() - 8usize]; + ["Offset of field: rac_decision_result::answers"] + [::std::mem::offset_of!(rac_decision_result, answers) - 0usize]; + ["Offset of field: rac_decision_result::answer_count"] + [::std::mem::offset_of!(rac_decision_result, answer_count) - 8usize]; + ["Offset of field: rac_decision_result::processing_time_ms"] + [::std::mem::offset_of!(rac_decision_result, processing_time_ms) - 16usize]; + ["Offset of field: rac_decision_result::model_id"] + [::std::mem::offset_of!(rac_decision_result, model_id) - 24usize]; + ["Offset of field: rac_decision_result::input_tokens"] + [::std::mem::offset_of!(rac_decision_result, input_tokens) - 32usize]; + ["Offset of field: rac_decision_result::prompt_format_version"] + [::std::mem::offset_of!(rac_decision_result, prompt_format_version) - 36usize]; +}; +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of rac_decision_service_ops"] + [::std::mem::size_of::() - 40usize]; + ["Alignment of rac_decision_service_ops"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: rac_decision_service_ops::initialize"] + [::std::mem::offset_of!(rac_decision_service_ops, initialize) - 0usize]; + ["Offset of field: rac_decision_service_ops::decide"] + [::std::mem::offset_of!(rac_decision_service_ops, decide) - 8usize]; + ["Offset of field: rac_decision_service_ops::cleanup"] + [::std::mem::offset_of!(rac_decision_service_ops, cleanup) - 16usize]; + ["Offset of field: rac_decision_service_ops::destroy"] + [::std::mem::offset_of!(rac_decision_service_ops, destroy) - 24usize]; + ["Offset of field: rac_decision_service_ops::create"] + [::std::mem::offset_of!(rac_decision_service_ops, create) - 32usize]; +}; +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of rac_decision_service"][::std::mem::size_of::() - 24usize]; + ["Alignment of rac_decision_service"][::std::mem::align_of::() - 8usize]; + ["Offset of field: rac_decision_service::ops"] + [::std::mem::offset_of!(rac_decision_service, ops) - 0usize]; + ["Offset of field: rac_decision_service::impl_"] + [::std::mem::offset_of!(rac_decision_service, impl_) - 8usize]; + ["Offset of field: rac_decision_service::model_id"] + [::std::mem::offset_of!(rac_decision_service, model_id) - 16usize]; +}; #[allow(clippy::unnecessary_operation, clippy::identity_op)] const _: () = { ["Size of rac_diarization_options"][::std::mem::size_of::() - 32usize]; @@ -5018,7 +5254,7 @@ const _: () = { }; #[allow(clippy::unnecessary_operation, clippy::identity_op)] const _: () = { - ["Size of rac_mlx_callbacks"][::std::mem::size_of::() - 208usize]; + ["Size of rac_mlx_callbacks"][::std::mem::size_of::() - 216usize]; ["Alignment of rac_mlx_callbacks"][::std::mem::align_of::() - 8usize]; ["Offset of field: rac_mlx_callbacks::struct_size"] [::std::mem::offset_of!(rac_mlx_callbacks, struct_size) - 0usize]; @@ -5072,6 +5308,8 @@ const _: () = { [::std::mem::offset_of!(rac_mlx_callbacks, llm_generate_chat_stream) - 192usize]; ["Offset of field: rac_mlx_callbacks::context_length"] [::std::mem::offset_of!(rac_mlx_callbacks, context_length) - 200usize]; + ["Offset of field: rac_mlx_callbacks::decision"] + [::std::mem::offset_of!(rac_mlx_callbacks, decision) - 208usize]; }; #[allow(clippy::unnecessary_operation, clippy::identity_op)] const _: () = { @@ -5229,6 +5467,8 @@ pub const RAC_MODEL_CATEGORY_SPEAKER_DIARIZATION: rac_model_category = 9; pub const RAC_MODEL_CATEGORY_SEMANTIC_SEGMENTATION: rac_model_category = 10; #[doc = "< Full-page optical character recognition"] pub const RAC_MODEL_CATEGORY_OCR: rac_model_category = 11; +#[doc = "< Joint decision scoring (choice/noul/score)"] +pub const RAC_MODEL_CATEGORY_DECISION: rac_model_category = 12; #[doc = "< Unknown category"] pub const RAC_MODEL_CATEGORY_UNKNOWN: rac_model_category = 99; pub const RAC_MODEL_FORMAT_UNSPECIFIED: rac_model_format = 0; @@ -5755,6 +5995,8 @@ pub const RAC_RESOURCE_TYPE_SEGMENTATION_MODEL: rac_resource_type = 8; pub const RAC_RESOURCE_TYPE_RERANK_MODEL: rac_resource_type = 9; #[doc = "< Full-page OCR model"] pub const RAC_RESOURCE_TYPE_OCR_MODEL: rac_resource_type = 10; +#[doc = "< Joint decision scoring model"] +pub const RAC_RESOURCE_TYPE_DECISION_MODEL: rac_resource_type = 11; #[allow(clippy::unnecessary_operation, clippy::identity_op)] const _: () = { ["Size of rac_lifecycle_metrics"][::std::mem::size_of::() - 56usize]; @@ -6949,17 +7191,17 @@ pub const RAC_PRIMITIVE_RERANK: rac_primitive = 11; pub const RAC_PRIMITIVE_EMBED_IMAGE: rac_primitive = 12; #[doc = " Optical character recognition (page or line image -> text + boxes). Promoted from\n RAC_PRIMITIVE_RESERVED_13 in ABI v11. Distinct from VLM (image + PROMPT -> free text):\n OCR takes no prompt, is not generative, and returns geometry alongside the text, so a\n CTC line recognizer cannot be honestly described by the VLM contract."] pub const RAC_PRIMITIVE_OCR: rac_primitive = 13; -#[doc = " Optical character recognition (page or line image -> text + boxes). Promoted from\n RAC_PRIMITIVE_RESERVED_13 in ABI v11. Distinct from VLM (image + PROMPT -> free text):\n OCR takes no prompt, is not generative, and returns geometry alongside the text, so a\n CTC line recognizer cannot be honestly described by the VLM contract."] -pub const RAC_PRIMITIVE_RESERVED_14: rac_primitive = 14; -#[doc = " Optical character recognition (page or line image -> text + boxes). Promoted from\n RAC_PRIMITIVE_RESERVED_13 in ABI v11. Distinct from VLM (image + PROMPT -> free text):\n OCR takes no prompt, is not generative, and returns geometry alongside the text, so a\n CTC line recognizer cannot be honestly described by the VLM contract."] +#[doc = " Joint decision scoring (state + typed questions -> calibrated answers). Promoted from\n RAC_PRIMITIVE_RESERVED_14 in ABI v13. A decision model is not generative: it scores every\n option of every question in one forward pass through a joint head, so GENERATE_TEXT\n cannot describe it and the answer layer needs probabilities, not tokens."] +pub const RAC_PRIMITIVE_DECIDE: rac_primitive = 14; +#[doc = " Joint decision scoring (state + typed questions -> calibrated answers). Promoted from\n RAC_PRIMITIVE_RESERVED_14 in ABI v13. A decision model is not generative: it scores every\n option of every question in one forward pass through a joint head, so GENERATE_TEXT\n cannot describe it and the answer layer needs probabilities, not tokens."] pub const RAC_PRIMITIVE_RESERVED_15: rac_primitive = 15; -#[doc = " Optical character recognition (page or line image -> text + boxes). Promoted from\n RAC_PRIMITIVE_RESERVED_13 in ABI v11. Distinct from VLM (image + PROMPT -> free text):\n OCR takes no prompt, is not generative, and returns geometry alongside the text, so a\n CTC line recognizer cannot be honestly described by the VLM contract."] +#[doc = " Joint decision scoring (state + typed questions -> calibrated answers). Promoted from\n RAC_PRIMITIVE_RESERVED_14 in ABI v13. A decision model is not generative: it scores every\n option of every question in one forward pass through a joint head, so GENERATE_TEXT\n cannot describe it and the answer layer needs probabilities, not tokens."] pub const RAC_PRIMITIVE_RESERVED_16: rac_primitive = 16; -#[doc = " Optical character recognition (page or line image -> text + boxes). Promoted from\n RAC_PRIMITIVE_RESERVED_13 in ABI v11. Distinct from VLM (image + PROMPT -> free text):\n OCR takes no prompt, is not generative, and returns geometry alongside the text, so a\n CTC line recognizer cannot be honestly described by the VLM contract."] +#[doc = " Joint decision scoring (state + typed questions -> calibrated answers). Promoted from\n RAC_PRIMITIVE_RESERVED_14 in ABI v13. A decision model is not generative: it scores every\n option of every question in one forward pass through a joint head, so GENERATE_TEXT\n cannot describe it and the answer layer needs probabilities, not tokens."] pub const RAC_PRIMITIVE_RESERVED_17: rac_primitive = 17; -#[doc = " Optical character recognition (page or line image -> text + boxes). Promoted from\n RAC_PRIMITIVE_RESERVED_13 in ABI v11. Distinct from VLM (image + PROMPT -> free text):\n OCR takes no prompt, is not generative, and returns geometry alongside the text, so a\n CTC line recognizer cannot be honestly described by the VLM contract."] +#[doc = " Joint decision scoring (state + typed questions -> calibrated answers). Promoted from\n RAC_PRIMITIVE_RESERVED_14 in ABI v13. A decision model is not generative: it scores every\n option of every question in one forward pass through a joint head, so GENERATE_TEXT\n cannot describe it and the answer layer needs probabilities, not tokens."] pub const RAC_PRIMITIVE_RESERVED_18: rac_primitive = 18; -#[doc = " Optical character recognition (page or line image -> text + boxes). Promoted from\n RAC_PRIMITIVE_RESERVED_13 in ABI v11. Distinct from VLM (image + PROMPT -> free text):\n OCR takes no prompt, is not generative, and returns geometry alongside the text, so a\n CTC line recognizer cannot be honestly described by the VLM contract."] +#[doc = " Joint decision scoring (state + typed questions -> calibrated answers). Promoted from\n RAC_PRIMITIVE_RESERVED_14 in ABI v13. A decision model is not generative: it scores every\n option of every question in one forward pass through a joint head, so GENERATE_TEXT\n cannot describe it and the answer layer needs probabilities, not tokens."] pub const RAC_PRIMITIVE_COUNT: rac_primitive = 19; pub const RAC_RUNTIME_UNSPECIFIED: rac_runtime_id = 0; #[doc = "< Plain CPU (SIMD ok)."] @@ -7055,8 +7297,8 @@ const _: () = { [::std::mem::offset_of!(rac_engine_vtable, image_embedding_ops) - 176usize]; ["Offset of field: rac_engine_vtable::ocr_ops"] [::std::mem::offset_of!(rac_engine_vtable, ocr_ops) - 184usize]; - ["Offset of field: rac_engine_vtable::reserved_slot_5"] - [::std::mem::offset_of!(rac_engine_vtable, reserved_slot_5) - 192usize]; + ["Offset of field: rac_engine_vtable::decision_ops"] + [::std::mem::offset_of!(rac_engine_vtable, decision_ops) - 192usize]; ["Offset of field: rac_engine_vtable::reserved_slot_6"] [::std::mem::offset_of!(rac_engine_vtable, reserved_slot_6) - 200usize]; ["Offset of field: rac_engine_vtable::reserved_slot_7"] @@ -7128,6 +7370,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 = " 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."] pub use self::rac_device as rac_device_t; #[doc = " @brief Generation mode"] @@ -7417,6 +7661,28 @@ unsafe extern "C" { pub fn rac_backend_llamacpp_register() -> rac_result_t; #[doc = " Unregisters the LlamaCPP backend.\n\n @return RAC_SUCCESS or error code"] pub fn rac_backend_llamacpp_unregister() -> rac_result_t; + #[doc = " Free every malloc-owned result field and zero the struct."] + pub fn rac_decision_result_free(result: *mut rac_decision_result_t); + #[doc = " The prompt-wording version the clef decision lineage serves. Engines that\n have no per-checkpoint override report this; it is kept in step with the\n cloud contract, so a caller pinning the version it saw in the cloud gets the\n same wording locally."] + pub fn rac_decision_default_prompt_format_version() -> u32; + pub fn rac_decision_create( + model_id: *const ::std::os::raw::c_char, + out_handle: *mut rac_handle_t, + ) -> rac_result_t; + pub fn rac_decision_initialize( + handle: rac_handle_t, + model_path: *const ::std::os::raw::c_char, + ) -> rac_result_t; + pub fn rac_decision_decide( + handle: rac_handle_t, + state: *const ::std::os::raw::c_char, + questions: *const rac_decision_question_t, + question_count: usize, + options: *const rac_decision_options_t, + out_result: *mut rac_decision_result_t, + ) -> rac_result_t; + pub fn rac_decision_cleanup(handle: rac_handle_t) -> rac_result_t; + pub fn rac_decision_destroy(handle: rac_handle_t); #[doc = " Free all malloc-owned fields in a success or partial-error backend result\n and zero the struct. NULL is accepted; calling again after the first free is\n safe because the first call clears every field."] pub fn rac_diarization_result_free(result: *mut rac_diarization_result_t); pub fn rac_diarization_create( diff --git a/tests/local_harness/fake_llm.rs b/tests/local_harness/fake_llm.rs index d77e854b..1fd32b5b 100644 --- a/tests/local_harness/fake_llm.rs +++ b/tests/local_harness/fake_llm.rs @@ -268,7 +268,7 @@ const ENGINE: sys::rac_engine_vtable = sys::rac_engine_vtable { rerank_ops: std::ptr::null(), image_embedding_ops: std::ptr::null(), ocr_ops: std::ptr::null_mut(), - reserved_slot_5: std::ptr::null(), + decision_ops: std::ptr::null(), reserved_slot_6: std::ptr::null(), reserved_slot_7: std::ptr::null(), reserved_slot_8: std::ptr::null(), diff --git a/versions.toml b/versions.toml index cb70954e..b085950a 100644 --- a/versions.toml +++ b/versions.toml @@ -17,15 +17,15 @@ decisions_contract_sha256 = "2dedb7959a12921efb82fa0e17bfdd0e78ee1cc21c80a1cc438 [sdk] # The published C++ desktop kit this tree builds against; bump the SHAs with it. kit_version = "0.20.38" -kit_release_tag = "cpp-desktop-v0.20.38-1" -idl_version = "1.3.0" -idl_schema_sha256 = "9dcf42f472090813edc5b76c8d4711b9e32813f29808a657baf317f5ad07d473" +kit_release_tag = "cpp-desktop-v0.20.38-2" +idl_version = "1.5.0" +idl_schema_sha256 = "faca2c22e518f3d41e534399b854bbbc9617d4a76511592a6013a888fdd3cf3d" idl_protoc_version = "35.1" -kit_sha256_macos_arm64 = "8e45a36b7d5dc12d76c29659b158d95d72cf171649d0ad961637cdfee09219a5" -kit_sha256_windows_x64 = "250d974c08f791ce6dd066e4115124b4b88955a959d68b80b89ff6d531998acb" -kit_sha256_windows_arm64 = "d0a6a2a241052278b2689a960ec7fcb9c066e29b9d9e5a8206d87e7946dce956" -kit_sha256_linux_x64 = "bfa4a2bfde01c6ea352ec6b04d1b18832eb9ff03015c34b040216651e7fabf32" -kit_sha256_linux_arm64 = "7e405e545485f8367192aae6bed36bad5e555715897d53b8284f90b9a3ef5e67" +kit_sha256_macos_arm64 = "413c636b311efa3c2e06e896f5fe25330c2cd339c5c2b1b7209c0e6c6c928a8b" +kit_sha256_windows_x64 = "5bd396b513664487f8ef4f4a2b44013b620605ff0c369a2a51af1188b40c031b" +kit_sha256_windows_arm64 = "fe55474adc1fbf308700a44db42c095a9a74af391fe9fc114f15f5e7523a3fd0" +kit_sha256_linux_x64 = "602eedd836137def5dc819d0eda192ab845f8a1046a7393ca2a3d855b250c978" +kit_sha256_linux_arm64 = "0f793bbdfd180aa85f1294adf4ce7831cb331df61496bc67ffeb65a3acf3a2ab" [swift] # The Apple MLX host's Swift SDK: sdk_package_version pins swift/Package.swift, From 0ac270f672c8899d8f34bf7726dceb9f857d0ee2 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 00:37:31 +0530 Subject: [PATCH 02/38] decisions: serve the local checkpoints through wally decisions One contract, two transports. --local (or a -m resolving to a local decision artifact) scores in-process through the kit's rac_decision_* component and rebuilds the same DecisionsResponse the cloud transport renders; --cloud forces the hosted path. The kit pin moves to cpp-desktop-v0.20.38-2 and the bindings are regenerated for ABI v13 (decision_ops in the vtable). Catalog: clef-flash-gguf and clef-flash-mlx-4bit, the only non-Language rows the LLM-only cut surfaces (is_llm widened to Language|Decision, mirrored in cmd_list's filter). label_mass is reported only on the cloud transport; the local head has no whole-distribution mass to give. Verified with the real GGUF: yes 93.6%, billing 95.3%, urgency 1.836. --- AGENTS.md | 9 + README.md | 13 + docs/MODELS.md | 22 +- scripts/build/sys-bindings.h | 3 + scripts/test/smoke-mlx.sh | 19 + src/catalog/catalog.rs | 132 +++- src/commands/cmd_decisions.rs | 570 +++++++++++++++++- src/commands/cmd_list.rs | 7 +- src/commands/model_labels.rs | 1 + src/sys/bindings.rs | 365 +++++------ tests/golden/expected/help__decide.stdout | 6 + tests/golden/expected/help__decisions.stdout | 6 + tests/golden/expected/local__backends.stdout | 2 +- .../expected/local__backends__--json.stdout | 2 +- .../local__models__list__--all.stdout | 1 + .../local__models__list__--all__--json.stdout | 2 +- .../expected/version__--json__version.stdout | 2 +- tests/golden/expected/version__version.stdout | 2 +- .../expected/version__version__--json.stdout | 2 +- 19 files changed, 969 insertions(+), 197 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eb0ca406..af781fbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,6 +102,15 @@ The separately vendored `contracts/wally-decisions-public-v1.openapi.json` is pinned to its InferenceInfra commit in `versions.toml`; the same generator emits `src/account/decisions_contract.rs` with `--operation-id createDecisions`. +`wally decisions` has two transports behind one contract. Cloud posts the +request as above; local (`--local`, or a `-m` that resolves to a local +decision artifact) runs it through the kit's `rac_decision_*` component and +maps the proto result back onto the same `DecisionsResponse`, so rendering and +`--json` are transport-independent. The two local rows (`clef-flash-gguf`, +`clef-flash-mlx-4bit`) are `ModelCategory::Decision` and are the only +non-Language entries the LLM-only catalog cut surfaces (`is_llm` in +`src/catalog/catalog.rs`, mirrored in `cmd_list.rs`'s row filter). + To re-vendor from an InferenceInfra checkout (records the source commit on the extract): diff --git a/README.md b/README.md index 68435e00..399aa52c 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,19 @@ Use `--json` for the raw API response, or `--request FILE` for the complete typed request shape. Decision models are refused by `wally run` and the coding tools, which point back here. +Eve is also runnable on this machine. Pull a local checkpoint and point +`-m` at it; `--local` (or just a local `-m`) scores in-process through the +SDK's decision component, `--cloud` forces the hosted path: + +```bash +wally models pull clef-flash-gguf +wally decisions --local -m clef-flash-gguf \ + --input "Checkout is blank after Pay" --ask "Is this a software bug?" +``` + +Both transports share the request flags, the human rendering and the `--json` +document, so the same invocation works locally and hosted. + ## Models at a glance | Family | Pull | diff --git a/docs/MODELS.md b/docs/MODELS.md index 7ba86ae4..b813344e 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -34,8 +34,20 @@ archive. The rows are commented out in `src/catalog/catalog.rs` under ### Decisions -`eve` is hosted only and returns label probabilities rather than generated -text. Use `wally decisions` (alias `wally decide`), not `wally run` or a coding -harness; it is the default model there, so `-m` is optional. It supports -yes/no (`--ask`), choices (`--choice`) and ordered levels (`--score`); -`wally decisions --help` shows the request-file and JSON forms. +Decision models return label probabilities rather than generated text. Use +`wally decisions` (alias `wally decide`), not `wally run` or a coding harness. +It supports yes/no (`--ask`), choices (`--choice`) and ordered levels +(`--score`); `wally decisions --help` shows the request-file and JSON forms. + +`eve` is the hosted decision model and the default, so `-m` is optional. Two +local checkpoints run the same questions on-device: + +| Id | Backend | Pull | +|---|---|---| +| `clef-flash-gguf` | llama.cpp (Q4_K_M) | `wally models pull clef-flash-gguf` | +| `clef-flash-mlx-4bit` | MLX (Apple) | `wally models pull clef-flash-mlx-4bit` | + +`wally decisions --local -m ` forces the local path and never touches the +network; a bare `-m ` selects it automatically. On Apple, MLX +scoring needs the Swift runtime release that carries the decision callback; +the GGUF row runs on every kit today. diff --git a/scripts/build/sys-bindings.h b/scripts/build/sys-bindings.h index b52bf7ac..96b9a2d0 100644 --- a/scripts/build/sys-bindings.h +++ b/scripts/build/sys-bindings.h @@ -13,6 +13,9 @@ #include "rac/core/rac_sdk_state.h" #include "rac/core/rac_types.h" #include "rac/desktop/rac_desktop.h" +#include "rac/features/decision/rac_decision_component.h" +#include "rac/features/decision/rac_decision_service.h" +#include "rac/features/decision/rac_decision_types.h" #include "rac/features/diarization/rac_diarization_service.h" #include "rac/features/diarization/rac_diarization_types.h" #include "rac/features/diffusion/rac_diffusion_service.h" diff --git a/scripts/test/smoke-mlx.sh b/scripts/test/smoke-mlx.sh index 17fbc139..2ee730a6 100755 --- a/scripts/test/smoke-mlx.sh +++ b/scripts/test/smoke-mlx.sh @@ -112,4 +112,23 @@ echo "VLM: SKIP (vlm disabled for llm-only cut)" # require_text "VLM" "$vlm_out" # printf '%s\n' "$vlm_out" +# Local decision scoring. Gated on the checkpoint being present or pullable; +# the GGUF row is the transport every kit can run, so it is the one exercised. +# Set WALLY_SMOKE_DECISION=0 to skip. This is the only place a real model +# proves `wally decisions --local` end-to-end. +DECISION_GGUF="${WALLY_SMOKE_DECISION_GGUF:-clef-flash-gguf}" +if [[ "${WALLY_SMOKE_DECISION:-1}" == "1" ]]; then + echo "Decision (local GGUF): $DECISION_GGUF" + pull_if_enabled "$DECISION_GGUF" + decision_out="$(wally decisions --local -m "$DECISION_GGUF" \ + --input "I was charged twice. Please refund the extra payment." \ + --choice "Team=billing,technical,other" \ + --ask "Is this a refund request?" \ + --score "Urgency=low,medium,high")" + require_text "Decision" "$decision_out" + printf '%s\n' "$decision_out" +else + echo "Decision: SKIP (WALLY_SMOKE_DECISION=0)" +fi + echo "smoke-mlx: ok (tts/stt/vlm skipped for llm-only cut)" diff --git a/src/catalog/catalog.rs b/src/catalog/catalog.rs index 1aab754e..7d0c9677 100644 --- a/src/catalog/catalog.rs +++ b/src/catalog/catalog.rs @@ -2500,6 +2500,89 @@ const SHERPA_SUPERTONIC_V3_FILES: &[CatalogFile] = &[ }, ]; +// LOCAL(decision): multi-file inventory for the MLX decision checkpoint. +// Same file set the Swift MLX loader needs (backbone config + shards + +// tokenizer + the joint-head config and weights). +const CLEF_FLASH_MLX_4BIT_FILES: &[CatalogFile] = &[ + CatalogFile { + url: "https://huggingface.co/mlx-community/clef-flash-4bit/resolve/main/chat_template.jinja", + filename: "chat_template.jinja", + required: false, + size_bytes: 7756, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/mlx-community/clef-flash-4bit/resolve/main/config.json", + filename: "config.json", + required: true, + size_bytes: 3722, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/mlx-community/clef-flash-4bit/resolve/main/generation_config.json", + filename: "generation_config.json", + required: false, + size_bytes: 116, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/mlx-community/clef-flash-4bit/resolve/main/joint_head.safetensors", + filename: "joint_head.safetensors", + required: true, + size_bytes: 243538016, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/mlx-community/clef-flash-4bit/resolve/main/joint_head_config.json", + filename: "joint_head_config.json", + required: true, + size_bytes: 119, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/mlx-community/clef-flash-4bit/resolve/main/model-00001-of-00002.safetensors", + filename: "model-00001-of-00002.safetensors", + required: true, + size_bytes: 5349769710, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/mlx-community/clef-flash-4bit/resolve/main/model-00002-of-00002.safetensors", + filename: "model-00002-of-00002.safetensors", + required: true, + size_bytes: 600449850, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/mlx-community/clef-flash-4bit/resolve/main/model.safetensors.index.json", + filename: "model.safetensors.index.json", + required: true, + size_bytes: 123592, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/mlx-community/clef-flash-4bit/resolve/main/processor_config.json", + filename: "processor_config.json", + required: false, + size_bytes: 1191, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/mlx-community/clef-flash-4bit/resolve/main/tokenizer.json", + filename: "tokenizer.json", + required: true, + size_bytes: 19989339, + checksum_sha256: None, + }, + CatalogFile { + url: "https://huggingface.co/mlx-community/clef-flash-4bit/resolve/main/tokenizer_config.json", + filename: "tokenizer_config.json", + required: true, + size_bytes: 1190, + checksum_sha256: None, + }, +]; + const CATALOG: &[CatalogEntry] = &[ // --- LLM (LlamaCpp / GGUF) --- // Name carries Q8_0 on purpose: this is the one GGUF artifact in the @@ -4401,6 +4484,45 @@ const CATALOG: &[CatalogEntry] = &[ merge_key: None, harness_compatible: false, }, + // LOCAL(decision): the two local decision checkpoints. The hosted model + // (`eve`) owns the cloud path in `wally decisions`; these rows are what + // that command loads when it runs on-device. Not part of the + // transliterated C++ catalog block above — if parse_catalog.py runs + // again, the rows must be carried in its source (the SDK's catalog.cpp). + CatalogEntry { + id: "clef-flash-gguf", + alias: Some("clef-gguf"), + name: "Clef Flash Q4_K_M (decision, GGUF)", + category: v1::ModelCategory::Decision, + framework: v1::InferenceFramework::LlamaCpp, + format: v1::ModelFormat::Gguf, + url: Some("https://huggingface.co/ggml-org/Clef-Flash-GGUF/resolve/main/Clef-Flash-Q4_K_M.gguf"), + files: &[], + download_size_bytes: 6486448192, + context_length: 0, + supports_thinking: false, + memory_required_bytes: 0, + cua_profile: "", + merge_key: Some("clef-flash"), + harness_compatible: false, + }, + CatalogEntry { + id: "clef-flash-mlx-4bit", + alias: Some("clef-mlx"), + name: "Clef Flash 4-bit (decision, MLX)", + category: v1::ModelCategory::Decision, + framework: v1::InferenceFramework::Mlx, + format: v1::ModelFormat::Safetensors, + url: None, + files: CLEF_FLASH_MLX_4BIT_FILES, + download_size_bytes: 6213978243, + context_length: 0, + supports_thinking: false, + memory_required_bytes: 0, + cua_profile: "", + merge_key: Some("clef-flash"), + harness_compatible: false, + }, ]; // AUTO-TRANSLITERATED DATA END @@ -4408,8 +4530,16 @@ const CATALOG: &[CatalogEntry] = &[ // modality's entries still live in CATALOG above, but are filtered out here, // so `models list`, lookups, suggestions and SDK registration all see LLMs // only. Delete is_llm and its four uses below to restore the full catalog. +// +// LOCAL(decision): decision rows are surfaced alongside LLMs. `wally +// decisions` loads them by catalog id, so they must resolve, register and +// list; every modality the cut hides (STT/TTS/VLM/...) stays hidden. This is +// additive: Language or Decision. fn is_llm(entry: &CatalogEntry) -> bool { - entry.category == v1::ModelCategory::Language + matches!( + entry.category, + v1::ModelCategory::Language | v1::ModelCategory::Decision + ) } // MLX is an Apple-only backend. On any other platform its entries are hidden diff --git a/src/commands/cmd_decisions.rs b/src/commands/cmd_decisions.rs index 2b8d6671..8744dad8 100644 --- a/src/commands/cmd_decisions.rs +++ b/src/commands/cmd_decisions.rs @@ -1,15 +1,28 @@ -//! Hosted decision scoring (`wally decisions`, alias `decide`): a decision -//! model answers typed questions with label probabilities instead of text. +//! Decision scoring (`wally decisions`, alias `decide`): a decision model +//! answers typed questions with label probabilities instead of text. +//! +//! Two transports, one request/response contract. `--cloud` (or no local +//! artifact for `--model`) scores on the hosted console; `--local` (or a +//! `--model` that resolves to an on-disk artifact) scores in-process through +//! the kit's `rac_decision_*` component. The request shape, the rendering and +//! `--json` output are shared, so a script can point the same invocation at +//! either transport. +use std::collections::BTreeMap; use std::collections::BTreeSet; +use std::ffi::CString; use std::io::Read; use std::time::Instant; use crate::account::decisions_contract as contract; use crate::account::{self, ConsoleClient, ConsoleSession}; +use crate::bootstrap::{self, GlobalOptions}; use crate::cli::{App, ValueType}; use crate::cli_formatter::{examples_footer, Example}; +use crate::commands::model_setup::ensure_model_ready; use crate::io::output as out; +use crate::io::proto::{parse_proto_buffer, serialize, v1, ProtoBuffer}; +use crate::sys; use crate::util::term; const DEFAULT_MODEL: &str = crate::harness::DEFAULT_DECISIONS_MODEL; @@ -46,18 +59,26 @@ fn nonblank(value: &str, label: &str, maximum: usize) -> Result<(), String> { Ok(()) } -fn validate(request: &contract::DecisionsRequest) -> Result<(), String> { - nonblank(&request.input, "input", 1_000_000)?; - nonblank(&request.model, "model", 128)?; - let mut chars = request.model.chars(); +/// The hosted console accepts a model id, not a path; the local transport +/// accepts a catalog id, a registered id, or a path on this machine. So the +/// console's id-shape rule is applied by the cloud branch, not here — a +/// local path would otherwise be refused before the transport is chosen. +fn validate_model_shape(model: &str) -> Result<(), String> { + nonblank(model, "model", 128)?; + let mut chars = model.chars(); if !chars.next().is_some_and(|c| c.is_ascii_alphanumeric()) - || !request - .model + || !model .chars() .all(|c| c.is_ascii_alphanumeric() || "._:/-".contains(c)) { return Err("model must be a valid Wally model id".to_string()); } + Ok(()) +} + +fn validate(request: &contract::DecisionsRequest) -> Result<(), String> { + nonblank(&request.input, "input", 1_000_000)?; + nonblank(&request.model, "model", 128)?; if !(1..=128).contains(&request.questions.len()) { return Err("provide 1 to 128 questions".to_string()); } @@ -438,7 +459,15 @@ fn top_row(rows: &[(String, f64)]) -> Option<&(String, f64)> { }) } -fn render_human(request: &contract::DecisionsRequest, response: &contract::DecisionsResponse) { +/// `label_mass_present` is the transport capability: the cloud server reports +/// the share of the whole next-token distribution that landed on the +/// question's labels, the local head does not. The low-fit note is only +/// printed when a mass actually exists to judge. +fn render_human( + request: &contract::DecisionsRequest, + response: &contract::DecisionsResponse, + label_mass_present: bool, +) { for (id, view) in question_views(request) { let Some(answer) = response.answers.0.get(&id) else { continue; @@ -464,7 +493,7 @@ fn render_human(request: &contract::DecisionsRequest, response: &contract::Decis probability * 100.0 )); } - if answer.label_mass < 0.5 { + if label_mass_present && answer.label_mass < 0.5 { out::result_line(" note: low fit — most probability was outside these labels"); } out::result_line(""); @@ -477,7 +506,304 @@ fn display_label_line(text: &str) -> String { text.chars().filter(|c| !c.is_control()).collect() } -fn run(p: &crate::cli::Parsed, json: bool) -> i32 { +/// Which transport `wally decisions` should use. `Auto` is resolved by +/// whether `--model` names a local artifact: a decision model on this machine +/// is what the command runs; otherwise the hosted console. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Transport { + Auto, + Local, + Cloud, +} + +fn transport_from_flags(p: &crate::cli::Parsed) -> Result { + let local = p.flag("--local"); + let cloud = p.flag("--cloud"); + match (local, cloud) { + (true, true) => Err("--local and --cloud are mutually exclusive".to_string()), + (true, false) => Ok(Transport::Local), + (false, true) => Ok(Transport::Cloud), + (false, false) => Ok(Transport::Auto), + } +} + +/// Whether `reference` is a local model id/alias/path the decision component +/// can load. A catalog decision row or a path on disk counts; a hosted model +/// id (`eve`) does not. +fn model_resolves_locally(reference: &str) -> bool { + match crate::catalog::model_ref::resolve(reference, None) { + Ok(resolved) => { + if resolved.from_catalog { + crate::catalog::find(reference) + .map(|entry| entry.category == v1::ModelCategory::Decision) + .unwrap_or(false) + } else { + // Registered ids (a manifest-restored pull) and local paths + // are local artifacts; a hosted id is not registered here. + true + } + } + Err(_) => false, + } +} + +/// The response shape `create_decisions` returns, rebuilt locally so the +/// shared renderer/`--json` path is identical across transports. `label_mass` +/// is left at its default 0 and the renderer is told it is absent: the local +/// head has no whole-distribution mass to report, and faking one would be a +/// lie the `--json` output carried. +fn local_response( + result: &v1::DecisionResult, + request: &contract::DecisionsRequest, +) -> contract::DecisionsResponse { + let mut answers = BTreeMap::new(); + for answer in &result.answers { + let r#type = match v1::DecisionQuestionType::try_from(answer.r#type) { + Ok(v1::DecisionQuestionType::Choice) => "choice", + Ok(v1::DecisionQuestionType::Noul) => "yes_no", + Ok(v1::DecisionQuestionType::Score) => "score", + _ => "", + } + .to_string(); + // The local head keys probabilities by the option key it was given. + // For a yes/no question the contract renders "yes"/"no", so the + // canonical true/false keys are re-keyed the same way the SDK does. + let mut probabilities = BTreeMap::new(); + for (key, probability) in &answer.probabilities { + let key = match (r#type.as_str(), key.as_str()) { + ("yes_no", "true") => "yes".to_string(), + ("yes_no", "false") => "no".to_string(), + (_, other) => other.to_string(), + }; + probabilities.insert(key, f64::from(*probability)); + } + let choice = match (&answer.answer, r#type.as_str()) { + (Some(v1::decision_answer::Answer::Choice(key)), "choice") => Some(key.clone()), + _ => None, + }; + let score = match &answer.answer { + Some(v1::decision_answer::Answer::Score(value)) => Some(f64::from(*value)), + _ => None, + }; + answers.insert( + answer.id.clone(), + contract::DecisionAnswer { + choice, + label_mass: 0.0, + probabilities: contract::LabelProbabilities(probabilities), + score, + r#type, + }, + ); + } + let input_tokens = result + .usage + .as_ref() + .map(|usage| i64::from(usage.input_tokens)) + .unwrap_or(0); + contract::DecisionsResponse { + answers: contract::DecisionAnswers(answers), + model: if result.model_id.is_empty() { + request.model.clone() + } else { + result.model_id.clone() + }, + object: "decision".to_string(), + prompt_format_version: i64::from(result.prompt_format_version), + usage: contract::DecisionUsage { + completion_tokens: 0, + prompt_tokens: input_tokens, + reasoning_tokens: None, + total_tokens: input_tokens, + }, + } +} + +/// Build the proto request the local component takes from the contract-shaped +/// request the CLI built. The cloud `yes_no` question is the local NOUL; choice +/// 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 { + let questions = request + .questions + .iter() + .map(|question| match question { + contract::DecisionQuestion::YesNoQuestion(value) => v1::DecisionQuestion { + id: value.id.clone(), + r#type: v1::DecisionQuestionType::Noul as i32, + instructions: value.question.clone(), + options: vec![ + v1::DecisionOption { + key: "true".to_string(), + description: value.yes.clone().unwrap_or_default(), + }, + v1::DecisionOption { + key: "false".to_string(), + description: value.no.clone().unwrap_or_default(), + }, + ], + }, + contract::DecisionQuestion::ChoiceQuestion(value) => v1::DecisionQuestion { + id: value.id.clone(), + r#type: v1::DecisionQuestionType::Choice as i32, + instructions: value.question.clone(), + options: value + .options + .iter() + .map(|option| v1::DecisionOption { + key: option.name.clone(), + description: option.description.clone().unwrap_or_default(), + }) + .collect(), + }, + contract::DecisionQuestion::ScoreQuestion(value) => v1::DecisionQuestion { + id: value.id.clone(), + r#type: v1::DecisionQuestionType::Score as i32, + instructions: value.question.clone(), + options: value + .levels + .iter() + .enumerate() + .map(|(index, level)| v1::DecisionOption { + key: index.to_string(), + description: level.clone(), + }) + .collect(), + }, + }) + .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, + }, + ); + + v1::DecisionRequest { + state: request.input.clone(), + questions, + options, + model_id: (!request.model.is_empty()).then(|| request.model.clone()), + } +} + +/// Score through the local component: resolve the model, load it through the +/// lifecycle (auto-pull when missing), drive the decision component, and hand +/// the mapped response back to the shared renderer. +fn run_local( + options: &GlobalOptions, + request: &contract::DecisionsRequest, + json: bool, +) -> i32 { + if bootstrap::bootstrap(options).is_err() { + return 1; + } + if let Err(error) = crate::commands::model_setup::refresh_registry() { + out::status_line(&format!("warning: registry refresh failed: {error}")); + } + + let model = match ensure_model_ready(options, &request.model) { + Ok(model) => model, + Err(exit_code) => return exit_code, + }; + + let mut handle: sys::rac_handle_t = std::ptr::null_mut(); + // SAFETY: `handle` is a valid out-pointer for the duration of the call. + let rc = unsafe { sys::rac_decision_component_create(&mut handle) }; + if rc != sys::SUCCESS || handle.is_null() { + out::error_line("failed to create decision component"); + return 1; + } + + let model_path = CString::new(model.primary_path.as_str()); + let model_id = CString::new(model.model_id.as_str()); + let model_name = CString::new(model.display_name.as_str()); + let (model_path, model_id, model_name) = match (model_path, model_id, model_name) { + (Ok(path), Ok(id), Ok(name)) => (path, id, name), + _ => { + out::error_line("model path, id or name contains a NUL byte"); + // SAFETY: handle was just created above. + unsafe { sys::rac_decision_component_destroy(handle) }; + return 1; + } + }; + // SAFETY: handle is the live component created above; the three C strings + // outlive this call. + let rc = unsafe { + sys::rac_decision_component_load_model( + handle, + model_path.as_ptr(), + model_id.as_ptr(), + model_name.as_ptr(), + ) + }; + if rc != sys::SUCCESS { + out::error_line(&format!( + "failed to load decision model: {}", + out::describe_result(rc) + )); + // SAFETY: handle is the live component created above. + unsafe { sys::rac_decision_component_destroy(handle) }; + return 1; + } + + let proto_request = local_request(request); + let bytes = serialize(&proto_request); + let mut out_buffer = ProtoBuffer::new(); + let started = Instant::now(); + // SAFETY: handle is the live, loaded component; bytes/out_buffer are valid + // for the duration of the call. + let proto_rc = unsafe { + sys::rac_decision_component_decide_proto( + handle, + bytes.as_ptr(), + bytes.len(), + out_buffer.as_mut_ptr(), + ) + }; + let result = match parse_proto_buffer::(out_buffer) { + Ok(result) if proto_rc == sys::SUCCESS => result, + Ok(result) if !result.answers.is_empty() => result, + Ok(_) => { + out::error_line("decision failed: "); + // SAFETY: handle is the live component created above. + unsafe { sys::rac_decision_component_destroy(handle) }; + return 1; + } + Err(error) => { + out::error_line(&format!("decision failed: {error}")); + // SAFETY: handle is the live component created above. + unsafe { sys::rac_decision_component_destroy(handle) }; + return 1; + } + }; + // SAFETY: handle is the live component created above; not used again. + unsafe { sys::rac_decision_component_destroy(handle) }; + + let latency_ms = started.elapsed().as_millis(); + let response = local_response(&result, request); + if json { + out::result_line(&crate::io::json::dump(&response.to_json())); + } else { + render_human(request, &response, false); + } + out::status_line(&format!( + "{} · {} tokens · local · {}ms", + response.model, response.usage.total_tokens, latency_ms + )); + 0 +} + +fn run(p: &crate::cli::Parsed, options: &GlobalOptions, json: bool) -> i32 { + let transport = match transport_from_flags(p) { + Ok(transport) => transport, + Err(error) => { + out::error_line(&error); + return 2; + } + }; let request = match build_request(p) { Ok(request) => request, Err(error) => { @@ -485,6 +811,33 @@ fn run(p: &crate::cli::Parsed, json: bool) -> i32 { return 2; } }; + let local = match transport { + Transport::Local => { + if !model_resolves_locally(&request.model) { + out::error_line(&format!( + "--local was given but '{}' does not resolve to a model on this machine", + request.model + )); + return 2; + } + true + } + Transport::Cloud => false, + Transport::Auto => model_resolves_locally(&request.model), + }; + if local { + if p.is_set("--no-retry") { + out::error_line("--no-retry is a cloud-only flag"); + return 2; + } + return run_local(options, &request, json); + } + // 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. + if let Err(error) = validate_model_shape(&request.model) { + out::error_line(&error); + return 2; + } let credentials = match account::load() { Ok(credentials) if credentials.signed_in() => credentials, Ok(_) => { @@ -539,7 +892,7 @@ fn run(p: &crate::cli::Parsed, json: bool) -> i32 { if json { out::result_line(&result.raw_json); } else { - render_human(&request, &result.response); + render_human(&request, &result.response, true); } let usage = &result.response.usage; let cost = session @@ -606,6 +959,14 @@ pub fn register_decisions(app: &mut App) { "Probability temperature (>0, at most 100)", ); cmd.add_flag("--json", "Print the raw response JSON"); + cmd.add_flag( + "--local", + "Score on this machine with a local decision model (never the network)", + ); + cmd.add_flag( + "--cloud", + "Score on your account's hosted endpoint (never a local model)", + ); cmd.add_flag("--no-retry", "Do not retry HTTP 429 or 503"); cmd.footer(&examples_footer(&[ Example::new( @@ -616,8 +977,12 @@ pub fn register_decisions(app: &mut App) { "wally decide --input-file ticket.txt --choice 'Owner=frontend,payments,account'", "", ), + Example::new( + "wally decisions --local -m clef-flash-gguf --input 'Checkout is blank' --ask 'Is this a bug?'", + "", + ), ])); - cmd.callback(|p, global| run(p, p.flag("--json") || global.json)); + cmd.callback(|p, global| run(p, global, p.flag("--json") || global.json)); } #[cfg(test)] @@ -754,4 +1119,183 @@ mod tests { assert_eq!(display_label(&long).chars().count(), LABEL_MAX_CHARS); assert!(display_label(&long).ends_with('…')); } + + fn ticket_request() -> contract::DecisionsRequest { + contract::DecisionsRequest { + model: "clef-flash-gguf".to_string(), + input: "ticket".to_string(), + questions: vec![ + contract::DecisionQuestion::YesNoQuestion(contract::YesNoQuestion { + id: "refund".to_string(), + question: "Refund?".to_string(), + r#type: "yes_no".to_string(), + yes: Some("asks for a refund".to_string()), + no: Some("no refund".to_string()), + }), + contract::DecisionQuestion::ScoreQuestion(contract::ScoreQuestion { + id: "urgency".to_string(), + question: "Urgency?".to_string(), + r#type: "score".to_string(), + levels: vec!["low".into(), "medium".into(), "high".into()], + }), + ], + ..Default::default() + } + } + + #[test] + fn local_request_maps_cloud_shapes_onto_the_proto_contract() { + let proto = local_request(&ticket_request()); + assert_eq!(proto.state, "ticket"); + assert_eq!(proto.questions.len(), 2); + let refund = &proto.questions[0]; + assert_eq!(refund.r#type, v1::DecisionQuestionType::Noul as i32); + // The engine finds the affirmative option by the canonical key. + assert_eq!(refund.options[0].key, "true"); + assert_eq!(refund.options[1].key, "false"); + let urgency = &proto.questions[1]; + assert_eq!(urgency.r#type, v1::DecisionQuestionType::Score as i32); + // Score levels become ordered keys with the level text as legend. + let keys: Vec<&str> = urgency.options.iter().map(|o| o.key.as_str()).collect(); + assert_eq!(keys, ["0", "1", "2"]); + assert_eq!(urgency.options[2].description, "high"); + } + + #[test] + fn local_response_maps_the_proto_result_back_to_the_cloud_shape() { + let request = ticket_request(); + let mut result = v1::DecisionResult { + model_id: "clef-flash-gguf".to_string(), + prompt_format_version: 3, + ..Default::default() + }; + let mut refund = v1::DecisionAnswer { + id: "refund".to_string(), + r#type: v1::DecisionQuestionType::Noul as i32, + confidence: 0.93, + ..Default::default() + }; + refund + .probabilities + .insert("true".to_string(), 0.93_f32); + refund + .probabilities + .insert("false".to_string(), 0.07_f32); + refund.answer = Some(v1::decision_answer::Answer::Noul(0.93)); + let mut urgency = v1::DecisionAnswer { + id: "urgency".to_string(), + r#type: v1::DecisionQuestionType::Score as i32, + ..Default::default() + }; + urgency.probabilities.insert("2".to_string(), 0.7_f32); + urgency.answer = Some(v1::decision_answer::Answer::Score(1.7)); + result.answers = vec![refund, urgency]; + result.usage = Some(v1::TokenUsage { + input_tokens: 97, + ..Default::default() + }); + + let response = local_response(&result, &request); + assert_eq!(response.model, "clef-flash-gguf"); + assert_eq!(response.prompt_format_version, 3); + assert_eq!(response.usage.total_tokens, 97); + let refund_answer = &response.answers.0["refund"]; + // yes_no keys are re-keyed to the contract's yes/no spelling. + assert_eq!(refund_answer.r#type, "yes_no"); + assert!((refund_answer.probabilities.0["yes"] - 0.93).abs() < 1e-6); + assert!((refund_answer.probabilities.0["no"] - 0.07).abs() < 1e-6); + let urgency_answer = &response.answers.0["urgency"]; + assert_eq!(urgency_answer.r#type, "score"); + assert!((urgency_answer.score.unwrap() - 1.7).abs() < 1e-6); + // The local head reports no whole-distribution mass; the renderer is + // told it is absent rather than printing a fake low-fit note. + assert_eq!(refund_answer.label_mass, 0.0); + } + + #[test] + fn local_response_choice_keeps_the_option_key() { + let request = contract::DecisionsRequest { + model: "clef-flash-gguf".to_string(), + input: "ticket".to_string(), + questions: vec![contract::DecisionQuestion::ChoiceQuestion( + contract::ChoiceQuestion { + id: "team".to_string(), + question: "Owner?".to_string(), + r#type: "choice".to_string(), + options: vec![ + contract::DecisionOption { + name: "billing".into(), + description: None, + }, + contract::DecisionOption { + name: "technical".into(), + description: None, + }, + ], + }, + )], + ..Default::default() + }; + let mut result = v1::DecisionResult::default(); + let mut answer = v1::DecisionAnswer { + id: "team".to_string(), + r#type: v1::DecisionQuestionType::Choice as i32, + ..Default::default() + }; + answer + .probabilities + .insert("billing".to_string(), 0.97_f32); + answer.probabilities.insert("technical".to_string(), 0.03_f32); + answer.answer = Some(v1::decision_answer::Answer::Choice("billing".to_string())); + result.answers = vec![answer]; + + let response = local_response(&result, &request); + let mapped = &response.answers.0["team"]; + assert_eq!(mapped.choice.as_deref(), Some("billing")); + assert!((mapped.probabilities.0["billing"] - 0.97).abs() < 1e-6); + assert!(mapped.score.is_none()); + } + + #[test] + fn a_decision_catalog_row_resolves_locally_and_a_hosted_id_does_not() { + // The engine sees this as an unregistered hosted id; it must not be + // treated as a local artifact (and must not try to load it as one). + assert!(!model_resolves_locally("eve")); + assert!(!model_resolves_locally("definitely-not-a-model")); + } + + #[test] + fn transport_flags_are_mutually_exclusive() { + use crate::cli::Parsed; + let flag = |name: &str| { + let mut parsed = Parsed::default(); + parsed + .name_to_spec + .insert(name.to_string(), name.to_string()); + parsed + .flag_values + .insert(name.to_string(), vec!["true".to_string()]); + parsed + }; + + let mut both = flag("--local"); + both.name_to_spec + .insert("--cloud".to_string(), "--cloud".to_string()); + both.flag_values + .insert("--cloud".to_string(), vec!["true".to_string()]); + assert!(transport_from_flags(&both).is_err()); + + assert_eq!( + transport_from_flags(&flag("--local")).unwrap(), + Transport::Local + ); + assert_eq!( + transport_from_flags(&flag("--cloud")).unwrap(), + Transport::Cloud + ); + assert_eq!( + transport_from_flags(&Parsed::default()).unwrap(), + Transport::Auto + ); + } } diff --git a/src/commands/cmd_list.rs b/src/commands/cmd_list.rs index bb5f7c48..7f76cfc4 100644 --- a/src/commands/cmd_list.rs +++ b/src/commands/cmd_list.rs @@ -119,8 +119,11 @@ fn group_models( continue; } // LLM-only surface: a downloaded non-LLM model restored from a manifest - // must not reappear in the list. - if model.category != v1::ModelCategory::Language as i32 { + // must not reappear in the list. LOCAL(decision): decision models are + // part of the list, matching catalog.rs::is_llm. + if model.category != v1::ModelCategory::Language as i32 + && model.category != v1::ModelCategory::Decision as i32 + { continue; } let key = crate::catalog::merge_key_for(&model.id); diff --git a/src/commands/model_labels.rs b/src/commands/model_labels.rs index 04ac4d20..1bc5ad8f 100644 --- a/src/commands/model_labels.rs +++ b/src/commands/model_labels.rs @@ -16,6 +16,7 @@ pub fn category(category: C) -> &'static str { C::SemanticSegmentation => "segment", C::ImageGeneration => "diffusion", C::Audio => "audio", + C::Decision => "decision", _ => "?", } } diff --git a/src/sys/bindings.rs b/src/sys/bindings.rs index 58a5cac2..3644c36e 100644 --- a/src/sys/bindings.rs +++ b/src/sys/bindings.rs @@ -562,6 +562,26 @@ pub type rac_file_list_directory_fn = ::std::option::Option< >; #[doc = " @brief Configuration for the desktop adapter. All fields optional."] pub type rac_desktop_adapter_config_t = rac_desktop_adapter_config; +#[doc = " @brief Capability loading state\n\n Mirrors Swift's CapabilityLoadingState enum."] +pub type rac_lifecycle_state = ::std::os::raw::c_uint; +#[doc = " @brief Resource type for lifecycle tracking\n\n Mirrors Swift's CapabilityResourceType enum."] +pub type rac_resource_type = ::std::os::raw::c_uint; +#[doc = " @brief Lifecycle metrics\n\n Mirrors Swift's ModelLifecycleMetrics struct."] +pub type rac_lifecycle_metrics_t = rac_lifecycle_metrics; +#[doc = " @brief Lifecycle configuration"] +pub type rac_lifecycle_config_t = rac_lifecycle_config; +#[doc = " @brief Service creation callback\n\n Called by the lifecycle manager to create a service for a given model ID.\n\n @param model_id The model ID to load\n @param user_data User-provided context\n @param out_service Output: Handle to the created service\n @return RAC_SUCCESS or error code"] +pub type rac_lifecycle_create_service_fn = ::std::option::Option< + unsafe extern "C" fn( + model_id: *const ::std::os::raw::c_char, + user_data: *mut ::std::os::raw::c_void, + out_service: *mut rac_handle_t, + ) -> rac_result_t, +>; +#[doc = " @brief Service destroy callback\n\n Called by the lifecycle manager to destroy a service.\n\n @param service Handle to the service to destroy\n @param user_data User-provided context"] +pub type rac_lifecycle_destroy_service_fn = ::std::option::Option< + unsafe extern "C" fn(service: rac_handle_t, user_data: *mut ::std::os::raw::c_void), +>; #[doc = " @brief Diffusion scheduler/sampler types\n\n Different scheduling algorithms for the denoising process.\n DPM++ 2M Karras is recommended for best quality/speed tradeoff."] pub type rac_diffusion_scheduler = ::std::os::raw::c_uint; #[doc = " @brief Model variant types\n\n Different Stable Diffusion model variants with different capabilities."] @@ -644,26 +664,6 @@ pub type rac_rag_stream_proto_callback_t = ::std::option::Option< user_data: *mut ::std::os::raw::c_void, ) -> rac_bool_t, >; -#[doc = " @brief Capability loading state\n\n Mirrors Swift's CapabilityLoadingState enum."] -pub type rac_lifecycle_state = ::std::os::raw::c_uint; -#[doc = " @brief Resource type for lifecycle tracking\n\n Mirrors Swift's CapabilityResourceType enum."] -pub type rac_resource_type = ::std::os::raw::c_uint; -#[doc = " @brief Lifecycle metrics\n\n Mirrors Swift's ModelLifecycleMetrics struct."] -pub type rac_lifecycle_metrics_t = rac_lifecycle_metrics; -#[doc = " @brief Lifecycle configuration"] -pub type rac_lifecycle_config_t = rac_lifecycle_config; -#[doc = " @brief Service creation callback\n\n Called by the lifecycle manager to create a service for a given model ID.\n\n @param model_id The model ID to load\n @param user_data User-provided context\n @param out_service Output: Handle to the created service\n @return RAC_SUCCESS or error code"] -pub type rac_lifecycle_create_service_fn = ::std::option::Option< - unsafe extern "C" fn( - model_id: *const ::std::os::raw::c_char, - user_data: *mut ::std::os::raw::c_void, - out_service: *mut rac_handle_t, - ) -> rac_result_t, ->; -#[doc = " @brief Service destroy callback\n\n Called by the lifecycle manager to destroy a service.\n\n @param service Handle to the service to destroy\n @param user_data User-provided context"] -pub type rac_lifecycle_destroy_service_fn = ::std::option::Option< - unsafe extern "C" fn(service: rac_handle_t, user_data: *mut ::std::os::raw::c_void), ->; #[doc = " One candidate document/passage handed to the reranker. Both pointers are\n caller-owned and only borrowed for the duration of the `rerank` call; the id\n is echoed back on the corresponding scored item."] pub type rac_rerank_candidate_t = rac_rerank_candidate; pub type rac_rerank_options_t = rac_rerank_options; @@ -2607,6 +2607,38 @@ pub struct rac_desktop_adapter_config { #[doc = " Directory for the secure-store files (created 0700 on POSIX if missing).\n NULL → the platform default config directory."] pub secure_store_dir: *const ::std::os::raw::c_char, } +#[doc = " @brief Lifecycle metrics\n\n Mirrors Swift's ModelLifecycleMetrics struct."] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct rac_lifecycle_metrics { + #[doc = " Total lifecycle events"] + pub total_events: i32, + #[doc = " Start time (ms since epoch)"] + pub start_time_ms: i64, + #[doc = " Last event time (ms since epoch, 0 if none)"] + pub last_event_time_ms: i64, + #[doc = " Total load attempts"] + pub total_loads: i32, + #[doc = " Successful loads"] + pub successful_loads: i32, + #[doc = " Failed loads"] + pub failed_loads: i32, + #[doc = " Average load time in milliseconds"] + pub average_load_time_ms: f64, + #[doc = " Total unloads"] + pub total_unloads: i32, +} +#[doc = " @brief Lifecycle configuration"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct rac_lifecycle_config { + #[doc = " Resource type for event tracking"] + pub resource_type: rac_resource_type_t, + #[doc = " Logger category (can be NULL for default)"] + pub logger_category: *const ::std::os::raw::c_char, + #[doc = " User data for callbacks"] + pub user_data: *mut ::std::os::raw::c_void, +} #[doc = " @brief Tokenizer configuration\n\n Configuration for downloading and using tokenizer files.\n The SDK will automatically download missing tokenizer files (vocab.json, merges.txt)\n from the specified source URL.\n\n Example for custom URL:\n @code\n rac_diffusion_tokenizer_config_t tokenizer_config = {\n .source = RAC_DIFFUSION_TOKENIZER_CUSTOM,\n .custom_base_url = \"https://huggingface.co/my-org/my-model/resolve/main/tokenizer\",\n .auto_download = RAC_TRUE\n };\n @endcode"] #[repr(C)] #[derive(Debug, Copy, Clone)] @@ -2813,38 +2845,6 @@ pub struct rac_diffusion_service { #[doc = " Model ID for reference"] pub model_id: *const ::std::os::raw::c_char, } -#[doc = " @brief Lifecycle metrics\n\n Mirrors Swift's ModelLifecycleMetrics struct."] -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct rac_lifecycle_metrics { - #[doc = " Total lifecycle events"] - pub total_events: i32, - #[doc = " Start time (ms since epoch)"] - pub start_time_ms: i64, - #[doc = " Last event time (ms since epoch, 0 if none)"] - pub last_event_time_ms: i64, - #[doc = " Total load attempts"] - pub total_loads: i32, - #[doc = " Successful loads"] - pub successful_loads: i32, - #[doc = " Failed loads"] - pub failed_loads: i32, - #[doc = " Average load time in milliseconds"] - pub average_load_time_ms: f64, - #[doc = " Total unloads"] - pub total_unloads: i32, -} -#[doc = " @brief Lifecycle configuration"] -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct rac_lifecycle_config { - #[doc = " Resource type for event tracking"] - pub resource_type: rac_resource_type_t, - #[doc = " Logger category (can be NULL for default)"] - pub logger_category: *const ::std::os::raw::c_char, - #[doc = " User data for callbacks"] - pub user_data: *mut ::std::os::raw::c_void, -} #[doc = " One candidate document/passage handed to the reranker. Both pointers are\n caller-owned and only borrowed for the duration of the `rerank` call; the id\n is echoed back on the corresponding scored item."] #[repr(C)] #[derive(Debug, Copy, Clone)] @@ -5751,6 +5751,66 @@ const _: () = { ["Offset of field: rac_desktop_adapter_config::secure_store_dir"] [::std::mem::offset_of!(rac_desktop_adapter_config, secure_store_dir) - 0usize]; }; +#[doc = "< Not loaded"] +pub const RAC_LIFECYCLE_STATE_IDLE: rac_lifecycle_state = 0; +#[doc = "< Currently loading"] +pub const RAC_LIFECYCLE_STATE_LOADING: rac_lifecycle_state = 1; +#[doc = "< Successfully loaded"] +pub const RAC_LIFECYCLE_STATE_LOADED: rac_lifecycle_state = 2; +#[doc = "< Load failed"] +pub const RAC_LIFECYCLE_STATE_FAILED: rac_lifecycle_state = 3; +pub const RAC_RESOURCE_TYPE_LLM_MODEL: rac_resource_type = 0; +pub const RAC_RESOURCE_TYPE_STT_MODEL: rac_resource_type = 1; +pub const RAC_RESOURCE_TYPE_TTS_VOICE: rac_resource_type = 2; +pub const RAC_RESOURCE_TYPE_VAD_MODEL: rac_resource_type = 3; +pub const RAC_RESOURCE_TYPE_DIARIZATION_MODEL: rac_resource_type = 4; +#[doc = "< Vision Language Model"] +pub const RAC_RESOURCE_TYPE_VLM_MODEL: rac_resource_type = 5; +#[doc = "< Diffusion/Image Generation Model"] +pub const RAC_RESOURCE_TYPE_DIFFUSION_MODEL: rac_resource_type = 6; +#[doc = "< Text embedding model"] +pub const RAC_RESOURCE_TYPE_EMBEDDINGS_MODEL: rac_resource_type = 7; +#[doc = "< Semantic segmentation model"] +pub const RAC_RESOURCE_TYPE_SEGMENTATION_MODEL: rac_resource_type = 8; +#[doc = "< Cross-encoder reranking model"] +pub const RAC_RESOURCE_TYPE_RERANK_MODEL: rac_resource_type = 9; +#[doc = "< Full-page OCR model"] +pub const RAC_RESOURCE_TYPE_OCR_MODEL: rac_resource_type = 10; +#[doc = "< Joint decision scoring model"] +pub const RAC_RESOURCE_TYPE_DECISION_MODEL: rac_resource_type = 11; +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of rac_lifecycle_metrics"][::std::mem::size_of::() - 56usize]; + ["Alignment of rac_lifecycle_metrics"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: rac_lifecycle_metrics::total_events"] + [::std::mem::offset_of!(rac_lifecycle_metrics, total_events) - 0usize]; + ["Offset of field: rac_lifecycle_metrics::start_time_ms"] + [::std::mem::offset_of!(rac_lifecycle_metrics, start_time_ms) - 8usize]; + ["Offset of field: rac_lifecycle_metrics::last_event_time_ms"] + [::std::mem::offset_of!(rac_lifecycle_metrics, last_event_time_ms) - 16usize]; + ["Offset of field: rac_lifecycle_metrics::total_loads"] + [::std::mem::offset_of!(rac_lifecycle_metrics, total_loads) - 24usize]; + ["Offset of field: rac_lifecycle_metrics::successful_loads"] + [::std::mem::offset_of!(rac_lifecycle_metrics, successful_loads) - 28usize]; + ["Offset of field: rac_lifecycle_metrics::failed_loads"] + [::std::mem::offset_of!(rac_lifecycle_metrics, failed_loads) - 32usize]; + ["Offset of field: rac_lifecycle_metrics::average_load_time_ms"] + [::std::mem::offset_of!(rac_lifecycle_metrics, average_load_time_ms) - 40usize]; + ["Offset of field: rac_lifecycle_metrics::total_unloads"] + [::std::mem::offset_of!(rac_lifecycle_metrics, total_unloads) - 48usize]; +}; +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of rac_lifecycle_config"][::std::mem::size_of::() - 24usize]; + ["Alignment of rac_lifecycle_config"][::std::mem::align_of::() - 8usize]; + ["Offset of field: rac_lifecycle_config::resource_type"] + [::std::mem::offset_of!(rac_lifecycle_config, resource_type) - 0usize]; + ["Offset of field: rac_lifecycle_config::logger_category"] + [::std::mem::offset_of!(rac_lifecycle_config, logger_category) - 8usize]; + ["Offset of field: rac_lifecycle_config::user_data"] + [::std::mem::offset_of!(rac_lifecycle_config, user_data) - 16usize]; +}; #[doc = "< DPM++ 2M Karras (recommended)"] pub const RAC_DIFFUSION_SCHEDULER_DPM_PP_2M_KARRAS: rac_diffusion_scheduler = 0; #[doc = "< DPM++ 2M"] @@ -5970,66 +6030,6 @@ const _: () = { ["Offset of field: rac_diffusion_service::model_id"] [::std::mem::offset_of!(rac_diffusion_service, model_id) - 16usize]; }; -#[doc = "< Not loaded"] -pub const RAC_LIFECYCLE_STATE_IDLE: rac_lifecycle_state = 0; -#[doc = "< Currently loading"] -pub const RAC_LIFECYCLE_STATE_LOADING: rac_lifecycle_state = 1; -#[doc = "< Successfully loaded"] -pub const RAC_LIFECYCLE_STATE_LOADED: rac_lifecycle_state = 2; -#[doc = "< Load failed"] -pub const RAC_LIFECYCLE_STATE_FAILED: rac_lifecycle_state = 3; -pub const RAC_RESOURCE_TYPE_LLM_MODEL: rac_resource_type = 0; -pub const RAC_RESOURCE_TYPE_STT_MODEL: rac_resource_type = 1; -pub const RAC_RESOURCE_TYPE_TTS_VOICE: rac_resource_type = 2; -pub const RAC_RESOURCE_TYPE_VAD_MODEL: rac_resource_type = 3; -pub const RAC_RESOURCE_TYPE_DIARIZATION_MODEL: rac_resource_type = 4; -#[doc = "< Vision Language Model"] -pub const RAC_RESOURCE_TYPE_VLM_MODEL: rac_resource_type = 5; -#[doc = "< Diffusion/Image Generation Model"] -pub const RAC_RESOURCE_TYPE_DIFFUSION_MODEL: rac_resource_type = 6; -#[doc = "< Text embedding model"] -pub const RAC_RESOURCE_TYPE_EMBEDDINGS_MODEL: rac_resource_type = 7; -#[doc = "< Semantic segmentation model"] -pub const RAC_RESOURCE_TYPE_SEGMENTATION_MODEL: rac_resource_type = 8; -#[doc = "< Cross-encoder reranking model"] -pub const RAC_RESOURCE_TYPE_RERANK_MODEL: rac_resource_type = 9; -#[doc = "< Full-page OCR model"] -pub const RAC_RESOURCE_TYPE_OCR_MODEL: rac_resource_type = 10; -#[doc = "< Joint decision scoring model"] -pub const RAC_RESOURCE_TYPE_DECISION_MODEL: rac_resource_type = 11; -#[allow(clippy::unnecessary_operation, clippy::identity_op)] -const _: () = { - ["Size of rac_lifecycle_metrics"][::std::mem::size_of::() - 56usize]; - ["Alignment of rac_lifecycle_metrics"] - [::std::mem::align_of::() - 8usize]; - ["Offset of field: rac_lifecycle_metrics::total_events"] - [::std::mem::offset_of!(rac_lifecycle_metrics, total_events) - 0usize]; - ["Offset of field: rac_lifecycle_metrics::start_time_ms"] - [::std::mem::offset_of!(rac_lifecycle_metrics, start_time_ms) - 8usize]; - ["Offset of field: rac_lifecycle_metrics::last_event_time_ms"] - [::std::mem::offset_of!(rac_lifecycle_metrics, last_event_time_ms) - 16usize]; - ["Offset of field: rac_lifecycle_metrics::total_loads"] - [::std::mem::offset_of!(rac_lifecycle_metrics, total_loads) - 24usize]; - ["Offset of field: rac_lifecycle_metrics::successful_loads"] - [::std::mem::offset_of!(rac_lifecycle_metrics, successful_loads) - 28usize]; - ["Offset of field: rac_lifecycle_metrics::failed_loads"] - [::std::mem::offset_of!(rac_lifecycle_metrics, failed_loads) - 32usize]; - ["Offset of field: rac_lifecycle_metrics::average_load_time_ms"] - [::std::mem::offset_of!(rac_lifecycle_metrics, average_load_time_ms) - 40usize]; - ["Offset of field: rac_lifecycle_metrics::total_unloads"] - [::std::mem::offset_of!(rac_lifecycle_metrics, total_unloads) - 48usize]; -}; -#[allow(clippy::unnecessary_operation, clippy::identity_op)] -const _: () = { - ["Size of rac_lifecycle_config"][::std::mem::size_of::() - 24usize]; - ["Alignment of rac_lifecycle_config"][::std::mem::align_of::() - 8usize]; - ["Offset of field: rac_lifecycle_config::resource_type"] - [::std::mem::offset_of!(rac_lifecycle_config, resource_type) - 0usize]; - ["Offset of field: rac_lifecycle_config::logger_category"] - [::std::mem::offset_of!(rac_lifecycle_config, logger_category) - 8usize]; - ["Offset of field: rac_lifecycle_config::user_data"] - [::std::mem::offset_of!(rac_lifecycle_config, user_data) - 16usize]; -}; #[allow(clippy::unnecessary_operation, clippy::identity_op)] const _: () = { ["Size of rac_rerank_candidate"][::std::mem::size_of::() - 16usize]; @@ -8757,6 +8757,89 @@ unsafe extern "C" { pub fn rac_desktop_device_model() -> *const ::std::os::raw::c_char; #[doc = " OS version (kernel release, capped at the backend's 20-char column); \"\" when unknown."] pub fn rac_desktop_os_version() -> *const ::std::os::raw::c_char; + #[doc = " @brief Create a lifecycle manager\n\n @param config Lifecycle configuration\n @param create_fn Service creation callback\n @param destroy_fn Service destruction callback (can be NULL)\n @param out_handle Output: Handle to the lifecycle manager\n @return RAC_SUCCESS or error code"] + pub fn rac_lifecycle_create( + config: *const rac_lifecycle_config_t, + create_fn: rac_lifecycle_create_service_fn, + destroy_fn: rac_lifecycle_destroy_service_fn, + out_handle: *mut rac_handle_t, + ) -> rac_result_t; + #[doc = " @brief Load a model with automatic event tracking\n\n Mirrors Swift's ManagedLifecycle.load(_:)\n If already loaded with same ID, skips duplicate load.\n\n @param handle Lifecycle manager handle\n @param model_path File path to the model (used for loading) - REQUIRED\n @param model_id Model identifier for telemetry (e.g., \"sherpa-onnx-whisper-tiny.en\")\n Optional: if NULL, defaults to model_path\n @param model_name Human-readable model name (e.g., \"Sherpa Whisper Tiny (ONNX)\")\n Optional: if NULL, defaults to model_id\n @param out_service Output: Handle to the loaded service\n @return RAC_SUCCESS or error code"] + pub fn rac_lifecycle_load( + handle: rac_handle_t, + model_path: *const ::std::os::raw::c_char, + model_id: *const ::std::os::raw::c_char, + model_name: *const ::std::os::raw::c_char, + out_service: *mut rac_handle_t, + ) -> rac_result_t; + #[doc = " @brief Unload the currently loaded model\n\n Mirrors Swift's ManagedLifecycle.unload()\n\n @param handle Lifecycle manager handle\n @return RAC_SUCCESS or error code"] + pub fn rac_lifecycle_unload(handle: rac_handle_t) -> rac_result_t; + #[doc = " @brief Reset all state\n\n Mirrors Swift's ManagedLifecycle.reset()\n\n @param handle Lifecycle manager handle\n @return RAC_SUCCESS or error code"] + pub fn rac_lifecycle_reset(handle: rac_handle_t) -> rac_result_t; + #[doc = " @brief Get current lifecycle state\n\n Mirrors Swift's ManagedLifecycle.state\n\n @param handle Lifecycle manager handle\n @return Current state"] + pub fn rac_lifecycle_get_state(handle: rac_handle_t) -> rac_lifecycle_state_t; + #[doc = " @brief Check if a model is loaded\n\n Mirrors Swift's ManagedLifecycle.isLoaded\n\n @param handle Lifecycle manager handle\n @return RAC_TRUE if loaded, RAC_FALSE otherwise"] + pub fn rac_lifecycle_is_loaded(handle: rac_handle_t) -> rac_bool_t; + #[doc = " @brief Get current model ID\n\n Mirrors Swift's ManagedLifecycle.currentModelId\n\n @param handle Lifecycle manager handle\n @return Current model ID (may be NULL if not loaded)"] + pub fn rac_lifecycle_get_model_id(handle: rac_handle_t) -> *const ::std::os::raw::c_char; + #[doc = " @brief Get current model name (human-readable)\n\n @param handle Lifecycle manager handle\n @return Current model name (may be NULL if not loaded)"] + pub fn rac_lifecycle_get_model_name(handle: rac_handle_t) -> *const ::std::os::raw::c_char; + #[doc = " @brief Get current service handle\n\n Mirrors Swift's ManagedLifecycle.currentService\n\n @param handle Lifecycle manager handle\n @return Current service handle (may be NULL if not loaded)"] + pub fn rac_lifecycle_get_service(handle: rac_handle_t) -> rac_handle_t; + #[doc = " @brief Require service or return error\n\n Mirrors Swift's ManagedLifecycle.requireService()\n\n @param handle Lifecycle manager handle\n @param out_service Output: Service handle\n @return RAC_SUCCESS or RAC_ERROR_NOT_INITIALIZED if not loaded"] + pub fn rac_lifecycle_require_service( + handle: rac_handle_t, + out_service: *mut rac_handle_t, + ) -> rac_result_t; + #[doc = " @brief Acquire (pin) the current service, preventing unload while held.\n\n Increments an internal refcount. The caller MUST call rac_lifecycle_release_service()\n when done. Unload/destroy will block until all acquired references are released.\n\n @param handle Lifecycle manager handle\n @param out_service Output: Service handle (pinned)\n @return RAC_SUCCESS or RAC_ERROR_NOT_INITIALIZED if not loaded"] + pub fn rac_lifecycle_acquire_service( + handle: rac_handle_t, + out_service: *mut rac_handle_t, + ) -> rac_result_t; + #[doc = " @brief Release a previously acquired service reference.\n\n @param handle Lifecycle manager handle"] + pub fn rac_lifecycle_release_service(handle: rac_handle_t); + #[doc = " @brief Track an operation error\n\n Mirrors Swift's ManagedLifecycle.trackOperationError(_:operation:)\n\n @param handle Lifecycle manager handle\n @param error_code Error code\n @param operation Operation name"] + pub fn rac_lifecycle_track_error( + handle: rac_handle_t, + error_code: rac_result_t, + operation: *const ::std::os::raw::c_char, + ); + #[doc = " @brief Get lifecycle metrics\n\n Mirrors Swift's ManagedLifecycle.getLifecycleMetrics()\n\n @param handle Lifecycle manager handle\n @param out_metrics Output: Lifecycle metrics\n @return RAC_SUCCESS or error code"] + pub fn rac_lifecycle_get_metrics( + handle: rac_handle_t, + out_metrics: *mut rac_lifecycle_metrics_t, + ) -> rac_result_t; + #[doc = " @brief Destroy a lifecycle manager\n\n @param handle Lifecycle manager handle"] + pub fn rac_lifecycle_destroy(handle: rac_handle_t); + #[doc = " @brief Get state name string\n\n @param state Lifecycle state\n @return Human-readable state name"] + pub fn rac_lifecycle_state_name(state: rac_lifecycle_state_t) -> *const ::std::os::raw::c_char; + #[doc = " @brief Get resource type name string\n\n @param type Resource type\n @return Human-readable resource type name"] + pub fn rac_resource_type_name(type_: rac_resource_type_t) -> *const ::std::os::raw::c_char; + pub fn rac_decision_component_create(out_handle: *mut rac_handle_t) -> rac_result_t; + pub fn rac_decision_component_is_loaded(handle: rac_handle_t) -> rac_bool_t; + pub fn rac_decision_component_get_model_id( + handle: rac_handle_t, + ) -> *const ::std::os::raw::c_char; + pub fn rac_decision_component_load_model( + handle: rac_handle_t, + model_path: *const ::std::os::raw::c_char, + model_id: *const ::std::os::raw::c_char, + model_name: *const ::std::os::raw::c_char, + ) -> rac_result_t; + pub fn rac_decision_component_unload(handle: rac_handle_t) -> rac_result_t; + pub fn rac_decision_component_get_state(handle: rac_handle_t) -> rac_lifecycle_state_t; + pub fn rac_decision_component_get_metrics( + handle: rac_handle_t, + out_metrics: *mut rac_lifecycle_metrics_t, + ) -> rac_result_t; + pub fn rac_decision_component_destroy(handle: rac_handle_t); + #[doc = " SDK-facing ABI over runanywhere.v1.DecisionRequest → DecisionResult."] + pub fn rac_decision_component_decide_proto( + handle: rac_handle_t, + request_proto_bytes: *const u8, + request_proto_size: usize, + out_result: *mut rac_proto_buffer_t, + ) -> rac_result_t; #[doc = " @brief Free diffusion result resources\n\n @param result Result to free (can be NULL)"] pub fn rac_diffusion_result_free(result: *mut rac_diffusion_result_t); #[doc = " @brief Create a diffusion service\n\n Routes through service registry to find appropriate backend.\n\n @param model_id Model identifier (registry ID or path to model)\n @param out_handle Output: Handle to the created service\n @return RAC_SUCCESS or error code"] @@ -9001,64 +9084,6 @@ unsafe extern "C" { session: rac_handle_t, out_stats: *mut rac_proto_buffer_t, ) -> rac_result_t; - #[doc = " @brief Create a lifecycle manager\n\n @param config Lifecycle configuration\n @param create_fn Service creation callback\n @param destroy_fn Service destruction callback (can be NULL)\n @param out_handle Output: Handle to the lifecycle manager\n @return RAC_SUCCESS or error code"] - pub fn rac_lifecycle_create( - config: *const rac_lifecycle_config_t, - create_fn: rac_lifecycle_create_service_fn, - destroy_fn: rac_lifecycle_destroy_service_fn, - out_handle: *mut rac_handle_t, - ) -> rac_result_t; - #[doc = " @brief Load a model with automatic event tracking\n\n Mirrors Swift's ManagedLifecycle.load(_:)\n If already loaded with same ID, skips duplicate load.\n\n @param handle Lifecycle manager handle\n @param model_path File path to the model (used for loading) - REQUIRED\n @param model_id Model identifier for telemetry (e.g., \"sherpa-onnx-whisper-tiny.en\")\n Optional: if NULL, defaults to model_path\n @param model_name Human-readable model name (e.g., \"Sherpa Whisper Tiny (ONNX)\")\n Optional: if NULL, defaults to model_id\n @param out_service Output: Handle to the loaded service\n @return RAC_SUCCESS or error code"] - pub fn rac_lifecycle_load( - handle: rac_handle_t, - model_path: *const ::std::os::raw::c_char, - model_id: *const ::std::os::raw::c_char, - model_name: *const ::std::os::raw::c_char, - out_service: *mut rac_handle_t, - ) -> rac_result_t; - #[doc = " @brief Unload the currently loaded model\n\n Mirrors Swift's ManagedLifecycle.unload()\n\n @param handle Lifecycle manager handle\n @return RAC_SUCCESS or error code"] - pub fn rac_lifecycle_unload(handle: rac_handle_t) -> rac_result_t; - #[doc = " @brief Reset all state\n\n Mirrors Swift's ManagedLifecycle.reset()\n\n @param handle Lifecycle manager handle\n @return RAC_SUCCESS or error code"] - pub fn rac_lifecycle_reset(handle: rac_handle_t) -> rac_result_t; - #[doc = " @brief Get current lifecycle state\n\n Mirrors Swift's ManagedLifecycle.state\n\n @param handle Lifecycle manager handle\n @return Current state"] - pub fn rac_lifecycle_get_state(handle: rac_handle_t) -> rac_lifecycle_state_t; - #[doc = " @brief Check if a model is loaded\n\n Mirrors Swift's ManagedLifecycle.isLoaded\n\n @param handle Lifecycle manager handle\n @return RAC_TRUE if loaded, RAC_FALSE otherwise"] - pub fn rac_lifecycle_is_loaded(handle: rac_handle_t) -> rac_bool_t; - #[doc = " @brief Get current model ID\n\n Mirrors Swift's ManagedLifecycle.currentModelId\n\n @param handle Lifecycle manager handle\n @return Current model ID (may be NULL if not loaded)"] - pub fn rac_lifecycle_get_model_id(handle: rac_handle_t) -> *const ::std::os::raw::c_char; - #[doc = " @brief Get current model name (human-readable)\n\n @param handle Lifecycle manager handle\n @return Current model name (may be NULL if not loaded)"] - pub fn rac_lifecycle_get_model_name(handle: rac_handle_t) -> *const ::std::os::raw::c_char; - #[doc = " @brief Get current service handle\n\n Mirrors Swift's ManagedLifecycle.currentService\n\n @param handle Lifecycle manager handle\n @return Current service handle (may be NULL if not loaded)"] - pub fn rac_lifecycle_get_service(handle: rac_handle_t) -> rac_handle_t; - #[doc = " @brief Require service or return error\n\n Mirrors Swift's ManagedLifecycle.requireService()\n\n @param handle Lifecycle manager handle\n @param out_service Output: Service handle\n @return RAC_SUCCESS or RAC_ERROR_NOT_INITIALIZED if not loaded"] - pub fn rac_lifecycle_require_service( - handle: rac_handle_t, - out_service: *mut rac_handle_t, - ) -> rac_result_t; - #[doc = " @brief Acquire (pin) the current service, preventing unload while held.\n\n Increments an internal refcount. The caller MUST call rac_lifecycle_release_service()\n when done. Unload/destroy will block until all acquired references are released.\n\n @param handle Lifecycle manager handle\n @param out_service Output: Service handle (pinned)\n @return RAC_SUCCESS or RAC_ERROR_NOT_INITIALIZED if not loaded"] - pub fn rac_lifecycle_acquire_service( - handle: rac_handle_t, - out_service: *mut rac_handle_t, - ) -> rac_result_t; - #[doc = " @brief Release a previously acquired service reference.\n\n @param handle Lifecycle manager handle"] - pub fn rac_lifecycle_release_service(handle: rac_handle_t); - #[doc = " @brief Track an operation error\n\n Mirrors Swift's ManagedLifecycle.trackOperationError(_:operation:)\n\n @param handle Lifecycle manager handle\n @param error_code Error code\n @param operation Operation name"] - pub fn rac_lifecycle_track_error( - handle: rac_handle_t, - error_code: rac_result_t, - operation: *const ::std::os::raw::c_char, - ); - #[doc = " @brief Get lifecycle metrics\n\n Mirrors Swift's ManagedLifecycle.getLifecycleMetrics()\n\n @param handle Lifecycle manager handle\n @param out_metrics Output: Lifecycle metrics\n @return RAC_SUCCESS or error code"] - pub fn rac_lifecycle_get_metrics( - handle: rac_handle_t, - out_metrics: *mut rac_lifecycle_metrics_t, - ) -> rac_result_t; - #[doc = " @brief Destroy a lifecycle manager\n\n @param handle Lifecycle manager handle"] - pub fn rac_lifecycle_destroy(handle: rac_handle_t); - #[doc = " @brief Get state name string\n\n @param state Lifecycle state\n @return Human-readable state name"] - pub fn rac_lifecycle_state_name(state: rac_lifecycle_state_t) -> *const ::std::os::raw::c_char; - #[doc = " @brief Get resource type name string\n\n @param type Resource type\n @return Human-readable resource type name"] - pub fn rac_resource_type_name(type_: rac_resource_type_t) -> *const ::std::os::raw::c_char; #[doc = " Free every malloc-owned result field and zero the struct."] pub fn rac_rerank_result_free(result: *mut rac_rerank_result_t); pub fn rac_rerank_create( diff --git a/tests/golden/expected/help__decide.stdout b/tests/golden/expected/help__decide.stdout index 37c66176..8999c6d3 100644 --- a/tests/golden/expected/help__decide.stdout +++ b/tests/golden/expected/help__decide.stdout @@ -14,9 +14,15 @@ Options: --request TEXT Complete DecisionsRequest JSON file (- for stdin) --temperature FLOAT Probability temperature (>0, at most 100) --json Print the raw response JSON + --local Score on this machine with a local decision model + (never the network) + --cloud Score on your account's hosted endpoint (never a + local model) --no-retry Do not retry HTTP 429 or 503 Examples: wally decisions --input 'Checkout is blank' --ask 'Is this a bug?' wally decide --input-file ticket.txt --choice 'Owner=frontend,payments,account' + + wally decisions --local -m clef-flash-gguf --input 'Checkout is blank' --ask 'Is this a bug?' diff --git a/tests/golden/expected/help__decisions.stdout b/tests/golden/expected/help__decisions.stdout index 37c66176..8999c6d3 100644 --- a/tests/golden/expected/help__decisions.stdout +++ b/tests/golden/expected/help__decisions.stdout @@ -14,9 +14,15 @@ Options: --request TEXT Complete DecisionsRequest JSON file (- for stdin) --temperature FLOAT Probability temperature (>0, at most 100) --json Print the raw response JSON + --local Score on this machine with a local decision model + (never the network) + --cloud Score on your account's hosted endpoint (never a + local model) --no-retry Do not retry HTTP 429 or 503 Examples: wally decisions --input 'Checkout is blank' --ask 'Is this a bug?' wally decide --input-file ticket.txt --choice 'Owner=frontend,payments,account' + + wally decisions --local -m clef-flash-gguf --input 'Checkout is blank' --ask 'Is this a bug?' diff --git a/tests/golden/expected/local__backends.stdout b/tests/golden/expected/local__backends.stdout index 4c44c570..ae8c7fde 100644 --- a/tests/golden/expected/local__backends.stdout +++ b/tests/golden/expected/local__backends.stdout @@ -1,4 +1,4 @@ NAME PRIORITY PRIMITIVES -llamacpp 100 embed, generate_text, rerank, vlm +llamacpp 100 decide, embed, generate_text, rerank, vlm onnx 50 diarize, embed, segment sherpa 90 detect_voice, synthesize, transcribe diff --git a/tests/golden/expected/local__backends__--json.stdout b/tests/golden/expected/local__backends__--json.stdout index 21579649..784885d7 100644 --- a/tests/golden/expected/local__backends__--json.stdout +++ b/tests/golden/expected/local__backends__--json.stdout @@ -1 +1 @@ -{"backends":[{"name":"llamacpp","display_name":"llama.cpp","version":"","priority":100,"primitives":[{"name":"embed"},{"name":"generate_text"},{"name":"rerank"},{"name":"vlm"}]},{"name":"onnx","display_name":"ONNX Runtime","version":"","priority":50,"primitives":[{"name":"diarize"},{"name":"embed"},{"name":"segment"}]},{"name":"sherpa","display_name":"Sherpa-ONNX","version":"","priority":90,"primitives":[{"name":"detect_voice"},{"name":"synthesize"},{"name":"transcribe"}]}]} +{"backends":[{"name":"llamacpp","display_name":"llama.cpp","version":"","priority":100,"primitives":[{"name":"decide"},{"name":"embed"},{"name":"generate_text"},{"name":"rerank"},{"name":"vlm"}]},{"name":"onnx","display_name":"ONNX Runtime","version":"","priority":50,"primitives":[{"name":"diarize"},{"name":"embed"},{"name":"segment"}]},{"name":"sherpa","display_name":"Sherpa-ONNX","version":"","priority":90,"primitives":[{"name":"detect_voice"},{"name":"synthesize"},{"name":"transcribe"}]}]} diff --git a/tests/golden/expected/local__models__list__--all.stdout b/tests/golden/expected/local__models__list__--all.stdout index 4734d60e..f761db32 100644 --- a/tests/golden/expected/local__models__list__--all.stdout +++ b/tests/golden/expected/local__models__list__--all.stdout @@ -7,6 +7,7 @@ bonsai-1.7b llm mlx/llama.cpp 236.8 MB no bonsai-27b llm mlx/llama.cpp 3.5 GB no bonsai-4b llm mlx/llama.cpp 545.8 MB no bonsai-8b llm mlx/llama.cpp 1.1 GB no +clef-flash-mlx-4bit decision mlx 5.8 GB no gemma-4-12b llm mlx/llama.cpp 6.6 GB no gemma-4-26b-a4b llm mlx/llama.cpp 15.8 GB no gemma-4-31b llm mlx/llama.cpp 17.1 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 21c380d4..99fa7908 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":""},{"id":"bonsai-27b","name":"Bonsai 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3803452480,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"bonsai-4b","name":"Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":572270624,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"bonsai-8b","name":"Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1158654496,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-12b","name":"Gemma 4 12B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":7121861440,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"gemma-4-31b","name":"Gemma 4 31B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":18323733440,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-e2b","name":"Gemma 4 E2B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3106738272,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-e4b","name":"Gemma 4 E4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4977171584,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"id":"lfm2.5-230m","name":"LiquidAI LFM2.5 230M","modality":"llm","backend":"llama.cpp","size_bytes":153406304,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"lfm2.5-350m","name":"LiquidAI LFM2.5 350M","modality":"llm","backend":"llama.cpp","size_bytes":229638144,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"maple-preview","name":"DeepGrove Maple Preview","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4984016416,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-nemotron-nano","name":"NVIDIA Llama 3.1 Nemotron Nano 8B","modality":"llm","backend":"mlx","size_bytes":4534806075,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-llama3.2","name":"Llama 3.2 1B Instruct","modality":"llm","backend":"mlx","size_bytes":712575975,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-nemotron-mini","name":"NVIDIA Nemotron Mini 4B Instruct","modality":"llm","backend":"mlx","size_bytes":2392679103,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"qwen3-0.6b","name":"Qwen3 0.6B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":670040064,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"qwen3.8-27b","name":"Qwen3.8 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":17106775008,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"mlx-ternary-bonsai-27b-2bit","name":"Ternary-Bonsai 27B","modality":"llm","backend":"mlx","size_bytes":8490785104,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"ternary-bonsai-4b","name":"Ternary-Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1137806656,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"ternary-bonsai-8b","name":"Ternary-Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":2310125920,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"smollm2-135m","name":"SmolLM2 135M","modality":"llm","backend":"llama.cpp","size_bytes":100663296,"downloaded":false,"harness_compatible":false,"local_path":""}]} +{"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":""},{"id":"bonsai-27b","name":"Bonsai 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3803452480,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"bonsai-4b","name":"Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":572270624,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"bonsai-8b","name":"Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1158654496,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"clef-flash-mlx-4bit","name":"Clef Flash 4-bit (decision, MLX)","modality":"decision","backend":"mlx","size_bytes":6213978243,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-12b","name":"Gemma 4 12B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":7121861440,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"gemma-4-31b","name":"Gemma 4 31B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":18323733440,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-e2b","name":"Gemma 4 E2B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3106738272,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-e4b","name":"Gemma 4 E4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4977171584,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"id":"lfm2.5-230m","name":"LiquidAI LFM2.5 230M","modality":"llm","backend":"llama.cpp","size_bytes":153406304,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"lfm2.5-350m","name":"LiquidAI LFM2.5 350M","modality":"llm","backend":"llama.cpp","size_bytes":229638144,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"maple-preview","name":"DeepGrove Maple Preview","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4984016416,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-nemotron-nano","name":"NVIDIA Llama 3.1 Nemotron Nano 8B","modality":"llm","backend":"mlx","size_bytes":4534806075,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-llama3.2","name":"Llama 3.2 1B Instruct","modality":"llm","backend":"mlx","size_bytes":712575975,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-nemotron-mini","name":"NVIDIA Nemotron Mini 4B Instruct","modality":"llm","backend":"mlx","size_bytes":2392679103,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"qwen3-0.6b","name":"Qwen3 0.6B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":670040064,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"qwen3.8-27b","name":"Qwen3.8 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":17106775008,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"mlx-ternary-bonsai-27b-2bit","name":"Ternary-Bonsai 27B","modality":"llm","backend":"mlx","size_bytes":8490785104,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"ternary-bonsai-4b","name":"Ternary-Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1137806656,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"ternary-bonsai-8b","name":"Ternary-Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":2310125920,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"smollm2-135m","name":"SmolLM2 135M","modality":"llm","backend":"llama.cpp","size_bytes":100663296,"downloaded":false,"harness_compatible":false,"local_path":""}]} diff --git a/tests/golden/expected/version__--json__version.stdout b/tests/golden/expected/version__--json__version.stdout index 0ae3538f..5c50a551 100644 --- a/tests/golden/expected/version__--json__version.stdout +++ b/tests/golden/expected/version__--json__version.stdout @@ -1 +1 @@ -{"wally":"","commons":"0.20.38","pinned_sdk":"0.20.38","idl_version":"1.3.0","idl_schema_sha256":"9dcf42f472090813edc5b76c8d4711b9e32813f29808a657baf317f5ad07d473","idl_protoc":"35.1","pin_ok":true} +{"wally":"","commons":"0.20.38","pinned_sdk":"0.20.38","idl_version":"1.5.0","idl_schema_sha256":"faca2c22e518f3d41e534399b854bbbc9617d4a76511592a6013a888fdd3cf3d","idl_protoc":"35.1","pin_ok":true} diff --git a/tests/golden/expected/version__version.stdout b/tests/golden/expected/version__version.stdout index 608df4f0..c72a6968 100644 --- a/tests/golden/expected/version__version.stdout +++ b/tests/golden/expected/version__version.stdout @@ -1,2 +1,2 @@ wally (commons 0.20.38) -idl 1.3.0 sha256 9dcf42f472090813edc5b76c8d4711b9e32813f29808a657baf317f5ad07d473 +idl 1.5.0 sha256 faca2c22e518f3d41e534399b854bbbc9617d4a76511592a6013a888fdd3cf3d diff --git a/tests/golden/expected/version__version__--json.stdout b/tests/golden/expected/version__version__--json.stdout index 0ae3538f..5c50a551 100644 --- a/tests/golden/expected/version__version__--json.stdout +++ b/tests/golden/expected/version__version__--json.stdout @@ -1 +1 @@ -{"wally":"","commons":"0.20.38","pinned_sdk":"0.20.38","idl_version":"1.3.0","idl_schema_sha256":"9dcf42f472090813edc5b76c8d4711b9e32813f29808a657baf317f5ad07d473","idl_protoc":"35.1","pin_ok":true} +{"wally":"","commons":"0.20.38","pinned_sdk":"0.20.38","idl_version":"1.5.0","idl_schema_sha256":"faca2c22e518f3d41e534399b854bbbc9617d4a76511592a6013a888fdd3cf3d","idl_protoc":"35.1","pin_ok":true} From cc319af23adf2131b3cc2f2835dcbe42f09c8a61 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 07:30:34 +0530 Subject: [PATCH 03/38] decisions: local checkpoints as clef-flash-9b / clef-flash-mlx, honest entitlement message Catalog rows follow the dual-backend convention: clef-flash-9b (GGUF, llama.cpp) and mlx-clef-flash-9b-4bit aliased clef-flash-mlx, one merged models list row, all three spellings resolve. Decision is the only non-Language category the llm-only cut surfaces. The 403 model_not_entitled message no longer sends anyone to re-login: entitlement is account-level, so a new session cannot change the answer; it now points at the local checkpoints instead. --- AGENTS.md | 4 +- README.md | 4 +- docs/MODELS.md | 4 +- scripts/test/smoke-mlx.sh | 2 +- src/account/console.rs | 12 ++++-- src/catalog/catalog.rs | 41 ++++++++++++++----- src/commands/cmd_decisions.rs | 10 ++--- tests/golden/expected/help__decide.stdout | 2 +- tests/golden/expected/help__decisions.stdout | 2 +- .../local__models__list__--all.stdout | 2 +- .../local__models__list__--all__--json.stdout | 2 +- tests/test_decisions_cli.py | 7 +++- 12 files changed, 61 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index af781fbb..784f3bea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,8 +106,8 @@ emits `src/account/decisions_contract.rs` with `--operation-id createDecisions`. request as above; local (`--local`, or a `-m` that resolves to a local decision artifact) runs it through the kit's `rac_decision_*` component and maps the proto result back onto the same `DecisionsResponse`, so rendering and -`--json` are transport-independent. The two local rows (`clef-flash-gguf`, -`clef-flash-mlx-4bit`) are `ModelCategory::Decision` and are the only +`--json` are transport-independent. The two local rows (`clef-flash-9b`, +`clef-flash-mlx`) are `ModelCategory::Decision` and are the only non-Language entries the LLM-only catalog cut surfaces (`is_llm` in `src/catalog/catalog.rs`, mirrored in `cmd_list.rs`'s row filter). diff --git a/README.md b/README.md index 399aa52c..80d6c18e 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,8 @@ Eve is also runnable on this machine. Pull a local checkpoint and point SDK's decision component, `--cloud` forces the hosted path: ```bash -wally models pull clef-flash-gguf -wally decisions --local -m clef-flash-gguf \ +wally models pull clef-flash-9b +wally decisions --local -m clef-flash-9b \ --input "Checkout is blank after Pay" --ask "Is this a software bug?" ``` diff --git a/docs/MODELS.md b/docs/MODELS.md index b813344e..9ed4322c 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -44,8 +44,8 @@ local checkpoints run the same questions on-device: | Id | Backend | Pull | |---|---|---| -| `clef-flash-gguf` | llama.cpp (Q4_K_M) | `wally models pull clef-flash-gguf` | -| `clef-flash-mlx-4bit` | MLX (Apple) | `wally models pull clef-flash-mlx-4bit` | +| `clef-flash-9b` | llama.cpp (Q4_K_M) | `wally models pull clef-flash-9b` | +| `clef-flash-mlx` | MLX (Apple) | `wally models pull clef-flash-mlx` | `wally decisions --local -m ` forces the local path and never touches the network; a bare `-m ` selects it automatically. On Apple, MLX diff --git a/scripts/test/smoke-mlx.sh b/scripts/test/smoke-mlx.sh index 2ee730a6..3cd12859 100755 --- a/scripts/test/smoke-mlx.sh +++ b/scripts/test/smoke-mlx.sh @@ -116,7 +116,7 @@ echo "VLM: SKIP (vlm disabled for llm-only cut)" # the GGUF row is the transport every kit can run, so it is the one exercised. # Set WALLY_SMOKE_DECISION=0 to skip. This is the only place a real model # proves `wally decisions --local` end-to-end. -DECISION_GGUF="${WALLY_SMOKE_DECISION_GGUF:-clef-flash-gguf}" +DECISION_GGUF="${WALLY_SMOKE_DECISION_GGUF:-clef-flash-9b}" if [[ "${WALLY_SMOKE_DECISION:-1}" == "1" ]]; then echo "Decision (local GGUF): $DECISION_GGUF" pull_if_enabled "$DECISION_GGUF" diff --git a/src/account/console.rs b/src/account/console.rs index c7da1c08..8d63fd8c 100644 --- a/src/account/console.rs +++ b/src/account/console.rs @@ -1216,10 +1216,16 @@ impl ConsoleClient { && refusal.as_ref().and_then(|error| error.code) == Some(decisions::ErrorCode::KModelNotEntitled) { + // Entitlement is account-level, not session-level: the token was + // just accepted (a bad one answers 401, which `call` refreshes + // and retries before ever reaching here), so telling the person + // to log in again sends them on a trip that cannot change the + // answer. Point at what can: the model is unavailable on THIS + // account, and the local checkpoints don't need entitlement. return failed(format!( - "your session is not entitled to {}; sign in again with `wally account logout` \ - and `wally account login`, then retry", - request_body.model + "{} is not available on this account (it is not enabled for it yet); \ + use a local model — `wally decisions --local -m clef-flash-9b`", + request_body.model, )); } // A refused request (an input over the model's window, a model that diff --git a/src/catalog/catalog.rs b/src/catalog/catalog.rs index 7d0c9677..11b7e763 100644 --- a/src/catalog/catalog.rs +++ b/src/catalog/catalog.rs @@ -2500,6 +2500,17 @@ const SHERPA_SUPERTONIC_V3_FILES: &[CatalogFile] = &[ }, ]; +// LOCAL(decision): single-artifact inventory for the GGUF decision +// checkpoint, expressed as a file list so it registers through the same +// category-preserving path as the MLX row. +const CLEF_FLASH_GGUF_FILES: &[CatalogFile] = &[CatalogFile { + url: "https://huggingface.co/ggml-org/Clef-Flash-GGUF/resolve/main/Clef-Flash-Q4_K_M.gguf", + filename: "Clef-Flash-Q4_K_M.gguf", + required: true, + size_bytes: 6486448192, + checksum_sha256: None, +}]; + // LOCAL(decision): multi-file inventory for the MLX decision checkpoint. // Same file set the Swift MLX loader needs (backbone config + shards + // tokenizer + the joint-head config and weights). @@ -4490,26 +4501,36 @@ const CATALOG: &[CatalogEntry] = &[ // transliterated C++ catalog block above — if parse_catalog.py runs // again, the rows must be carried in its source (the SDK's catalog.cpp). CatalogEntry { - id: "clef-flash-gguf", - alias: Some("clef-gguf"), - name: "Clef Flash Q4_K_M (decision, GGUF)", + id: "clef-flash-9b", + alias: Some("clef-flash"), + name: "Clef Flash 9B Q4_K_M (decision, GGUF)", category: v1::ModelCategory::Decision, framework: v1::InferenceFramework::LlamaCpp, format: v1::ModelFormat::Gguf, - url: Some("https://huggingface.co/ggml-org/Clef-Flash-GGUF/resolve/main/Clef-Flash-Q4_K_M.gguf"), - files: &[], + // One file, but registered through the file-list path: the SDK's + // single-file URL factory drops a Decision category (it has no + // DECISION arm in model_info_make_proto's proto->C category mapper, + // tracked separately on the SDK side), and the list filter then hides + // the UNKNOWN row. The file-list path passes the category straight + // through, the same way the MLX row registers. + url: None, + files: CLEF_FLASH_GGUF_FILES, download_size_bytes: 6486448192, context_length: 0, supports_thinking: false, memory_required_bytes: 0, cua_profile: "", - merge_key: Some("clef-flash"), + merge_key: Some("clef-flash-9b"), harness_compatible: false, }, CatalogEntry { - id: "clef-flash-mlx-4bit", - alias: Some("clef-mlx"), - name: "Clef Flash 4-bit (decision, MLX)", + // The `mlx-` prefix is the same backend-variant convention every + // other model uses (`mlx-qwen3-0.6b-4bit`). `clef-flash-mlx` is the + // short spelling: the id without the prefix would name the llama.cpp + // build, so the alias carries the suffix the rest of the CLI docs use. + id: "mlx-clef-flash-9b-4bit", + alias: Some("clef-flash-mlx"), + name: "Clef Flash 9B 4-bit (decision, MLX)", category: v1::ModelCategory::Decision, framework: v1::InferenceFramework::Mlx, format: v1::ModelFormat::Safetensors, @@ -4520,7 +4541,7 @@ const CATALOG: &[CatalogEntry] = &[ supports_thinking: false, memory_required_bytes: 0, cua_profile: "", - merge_key: Some("clef-flash"), + merge_key: Some("clef-flash-9b"), harness_compatible: false, }, ]; diff --git a/src/commands/cmd_decisions.rs b/src/commands/cmd_decisions.rs index 8744dad8..9240f19f 100644 --- a/src/commands/cmd_decisions.rs +++ b/src/commands/cmd_decisions.rs @@ -978,7 +978,7 @@ pub fn register_decisions(app: &mut App) { "", ), Example::new( - "wally decisions --local -m clef-flash-gguf --input 'Checkout is blank' --ask 'Is this a bug?'", + "wally decisions --local -m clef-flash-9b --input 'Checkout is blank' --ask 'Is this a bug?'", "", ), ])); @@ -1122,7 +1122,7 @@ mod tests { fn ticket_request() -> contract::DecisionsRequest { contract::DecisionsRequest { - model: "clef-flash-gguf".to_string(), + model: "clef-flash-9b".to_string(), input: "ticket".to_string(), questions: vec![ contract::DecisionQuestion::YesNoQuestion(contract::YesNoQuestion { @@ -1165,7 +1165,7 @@ mod tests { fn local_response_maps_the_proto_result_back_to_the_cloud_shape() { let request = ticket_request(); let mut result = v1::DecisionResult { - model_id: "clef-flash-gguf".to_string(), + model_id: "clef-flash-9b".to_string(), prompt_format_version: 3, ..Default::default() }; @@ -1196,7 +1196,7 @@ mod tests { }); let response = local_response(&result, &request); - assert_eq!(response.model, "clef-flash-gguf"); + assert_eq!(response.model, "clef-flash-9b"); assert_eq!(response.prompt_format_version, 3); assert_eq!(response.usage.total_tokens, 97); let refund_answer = &response.answers.0["refund"]; @@ -1215,7 +1215,7 @@ mod tests { #[test] fn local_response_choice_keeps_the_option_key() { let request = contract::DecisionsRequest { - model: "clef-flash-gguf".to_string(), + model: "clef-flash-9b".to_string(), input: "ticket".to_string(), questions: vec![contract::DecisionQuestion::ChoiceQuestion( contract::ChoiceQuestion { diff --git a/tests/golden/expected/help__decide.stdout b/tests/golden/expected/help__decide.stdout index 8999c6d3..ccd334a6 100644 --- a/tests/golden/expected/help__decide.stdout +++ b/tests/golden/expected/help__decide.stdout @@ -25,4 +25,4 @@ Examples: wally decide --input-file ticket.txt --choice 'Owner=frontend,payments,account' - wally decisions --local -m clef-flash-gguf --input 'Checkout is blank' --ask 'Is this a bug?' + wally decisions --local -m clef-flash-9b --input 'Checkout is blank' --ask 'Is this a bug?' diff --git a/tests/golden/expected/help__decisions.stdout b/tests/golden/expected/help__decisions.stdout index 8999c6d3..ccd334a6 100644 --- a/tests/golden/expected/help__decisions.stdout +++ b/tests/golden/expected/help__decisions.stdout @@ -25,4 +25,4 @@ Examples: wally decide --input-file ticket.txt --choice 'Owner=frontend,payments,account' - wally decisions --local -m clef-flash-gguf --input 'Checkout is blank' --ask 'Is this a bug?' + wally decisions --local -m clef-flash-9b --input 'Checkout is blank' --ask 'Is this a bug?' diff --git a/tests/golden/expected/local__models__list__--all.stdout b/tests/golden/expected/local__models__list__--all.stdout index f761db32..52f00ac6 100644 --- a/tests/golden/expected/local__models__list__--all.stdout +++ b/tests/golden/expected/local__models__list__--all.stdout @@ -7,7 +7,7 @@ bonsai-1.7b llm mlx/llama.cpp 236.8 MB no bonsai-27b llm mlx/llama.cpp 3.5 GB no bonsai-4b llm mlx/llama.cpp 545.8 MB no bonsai-8b llm mlx/llama.cpp 1.1 GB no -clef-flash-mlx-4bit decision mlx 5.8 GB no +clef-flash-9b decision mlx/llama.cpp 6.0 GB no gemma-4-12b llm mlx/llama.cpp 6.6 GB no gemma-4-26b-a4b llm mlx/llama.cpp 15.8 GB no gemma-4-31b llm mlx/llama.cpp 17.1 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 99fa7908..0cf380cf 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":""},{"id":"bonsai-27b","name":"Bonsai 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3803452480,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"bonsai-4b","name":"Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":572270624,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"bonsai-8b","name":"Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1158654496,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"clef-flash-mlx-4bit","name":"Clef Flash 4-bit (decision, MLX)","modality":"decision","backend":"mlx","size_bytes":6213978243,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-12b","name":"Gemma 4 12B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":7121861440,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"gemma-4-31b","name":"Gemma 4 31B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":18323733440,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-e2b","name":"Gemma 4 E2B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3106738272,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-e4b","name":"Gemma 4 E4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4977171584,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"id":"lfm2.5-230m","name":"LiquidAI LFM2.5 230M","modality":"llm","backend":"llama.cpp","size_bytes":153406304,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"lfm2.5-350m","name":"LiquidAI LFM2.5 350M","modality":"llm","backend":"llama.cpp","size_bytes":229638144,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"maple-preview","name":"DeepGrove Maple Preview","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4984016416,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-nemotron-nano","name":"NVIDIA Llama 3.1 Nemotron Nano 8B","modality":"llm","backend":"mlx","size_bytes":4534806075,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-llama3.2","name":"Llama 3.2 1B Instruct","modality":"llm","backend":"mlx","size_bytes":712575975,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-nemotron-mini","name":"NVIDIA Nemotron Mini 4B Instruct","modality":"llm","backend":"mlx","size_bytes":2392679103,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"qwen3-0.6b","name":"Qwen3 0.6B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":670040064,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"qwen3.8-27b","name":"Qwen3.8 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":17106775008,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"mlx-ternary-bonsai-27b-2bit","name":"Ternary-Bonsai 27B","modality":"llm","backend":"mlx","size_bytes":8490785104,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"ternary-bonsai-4b","name":"Ternary-Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1137806656,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"ternary-bonsai-8b","name":"Ternary-Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":2310125920,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"smollm2-135m","name":"SmolLM2 135M","modality":"llm","backend":"llama.cpp","size_bytes":100663296,"downloaded":false,"harness_compatible":false,"local_path":""}]} +{"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":""},{"id":"bonsai-27b","name":"Bonsai 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3803452480,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"bonsai-4b","name":"Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":572270624,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"bonsai-8b","name":"Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1158654496,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"clef-flash-9b","name":"Clef Flash 9B 4-bit (decision, MLX)","modality":"decision","backend":"mlx/llama.cpp","size_bytes":6486448192,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-12b","name":"Gemma 4 12B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":7121861440,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"gemma-4-31b","name":"Gemma 4 31B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":18323733440,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-e2b","name":"Gemma 4 E2B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3106738272,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-e4b","name":"Gemma 4 E4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4977171584,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"id":"lfm2.5-230m","name":"LiquidAI LFM2.5 230M","modality":"llm","backend":"llama.cpp","size_bytes":153406304,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"lfm2.5-350m","name":"LiquidAI LFM2.5 350M","modality":"llm","backend":"llama.cpp","size_bytes":229638144,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"maple-preview","name":"DeepGrove Maple Preview","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4984016416,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-nemotron-nano","name":"NVIDIA Llama 3.1 Nemotron Nano 8B","modality":"llm","backend":"mlx","size_bytes":4534806075,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-llama3.2","name":"Llama 3.2 1B Instruct","modality":"llm","backend":"mlx","size_bytes":712575975,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-nemotron-mini","name":"NVIDIA Nemotron Mini 4B Instruct","modality":"llm","backend":"mlx","size_bytes":2392679103,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"qwen3-0.6b","name":"Qwen3 0.6B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":670040064,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"qwen3.8-27b","name":"Qwen3.8 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":17106775008,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"mlx-ternary-bonsai-27b-2bit","name":"Ternary-Bonsai 27B","modality":"llm","backend":"mlx","size_bytes":8490785104,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"ternary-bonsai-4b","name":"Ternary-Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1137806656,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"ternary-bonsai-8b","name":"Ternary-Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":2310125920,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"smollm2-135m","name":"SmolLM2 135M","modality":"llm","backend":"llama.cpp","size_bytes":100663296,"downloaded":false,"harness_compatible":false,"local_path":""}]} diff --git a/tests/test_decisions_cli.py b/tests/test_decisions_cli.py index cdff0a81..38af39ef 100644 --- a/tests/test_decisions_cli.py +++ b/tests/test_decisions_cli.py @@ -198,8 +198,11 @@ def main(): forbidden = invoke(binary, env, "decisions", "--input", "forbidden", "--ask", "Okay?") assert forbidden.returncode == 1 - assert "not entitled to eve" in forbidden.stderr, forbidden.stderr - assert "wally account login" in forbidden.stderr, forbidden.stderr + assert "eve is not available on this account" in forbidden.stderr, forbidden.stderr + # Entitlement is account-level; re-login cannot change it, so the + # message must not send anyone down that path. + assert "wally account login" not in forbidden.stderr, forbidden.stderr + assert "--local" in forbidden.stderr, forbidden.stderr too_long = invoke(binary, env, "decisions", "--input", "too-long", "--ask", "Okay?") assert too_long.returncode == 1 From 22d4c673a7df1aa63922918b66b073cdc6b29689 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 07:49:56 +0530 Subject: [PATCH 04/38] decisions: rustfmt the local-mapping tests --- src/commands/cmd_decisions.rs | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/commands/cmd_decisions.rs b/src/commands/cmd_decisions.rs index 9240f19f..cd58da1e 100644 --- a/src/commands/cmd_decisions.rs +++ b/src/commands/cmd_decisions.rs @@ -674,12 +674,13 @@ 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()).then(|| { + v1::DecisionOptions { + temperature: request.temperature.unwrap_or(0.0) as f32, + prompt_format_version: request.prompt_format_version.unwrap_or(0) as u32, + } + }); v1::DecisionRequest { state: request.input.clone(), @@ -692,11 +693,7 @@ fn local_request(request: &contract::DecisionsRequest) -> v1::DecisionRequest { /// Score through the local component: resolve the model, load it through the /// lifecycle (auto-pull when missing), drive the decision component, and hand /// the mapped response back to the shared renderer. -fn run_local( - options: &GlobalOptions, - request: &contract::DecisionsRequest, - json: bool, -) -> i32 { +fn run_local(options: &GlobalOptions, request: &contract::DecisionsRequest, json: bool) -> i32 { if bootstrap::bootstrap(options).is_err() { return 1; } @@ -1175,12 +1172,8 @@ mod tests { confidence: 0.93, ..Default::default() }; - refund - .probabilities - .insert("true".to_string(), 0.93_f32); - refund - .probabilities - .insert("false".to_string(), 0.07_f32); + refund.probabilities.insert("true".to_string(), 0.93_f32); + refund.probabilities.insert("false".to_string(), 0.07_f32); refund.answer = Some(v1::decision_answer::Answer::Noul(0.93)); let mut urgency = v1::DecisionAnswer { id: "urgency".to_string(), @@ -1242,10 +1235,10 @@ mod tests { r#type: v1::DecisionQuestionType::Choice as i32, ..Default::default() }; + answer.probabilities.insert("billing".to_string(), 0.97_f32); answer .probabilities - .insert("billing".to_string(), 0.97_f32); - answer.probabilities.insert("technical".to_string(), 0.03_f32); + .insert("technical".to_string(), 0.03_f32); answer.answer = Some(v1::decision_answer::Answer::Choice("billing".to_string())); result.answers = vec![answer]; From 35f98b854a9a63acec52a2118629650e852c7ff8 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 08:21:55 +0530 Subject: [PATCH 05/38] sdk: pin the Apple Swift host to v0.20.38 The tag carries the decision callback (callbacks.decision = mlxDecision), so the MLX path can score local decision models. Verified by building the MLX host from a clean v0.20.38 checkout and scoring the canonical ticket (yes 91.4%, billing 92.8%, urgency 86.7%). sdk_package_version stays 0.20.37: that pin is the release-dist fallback (runanywhere-swift), which has no 0.20.38 tag yet. sdk_ci_ref is the checkout Wally CI actually builds from. --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- versions.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf919878..e287e507 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,7 @@ jobs: with: persist-credentials: false repository: RunanywhereAI/runanywhere-sdks - ref: b3a2b0147d8c66190c14a53bc04286efd6a4d64a + ref: v0.20.38 path: .deps/runanywhere-sdks - name: Install ninja run: brew install ninja diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 812a871a..3b6c1f8a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: with: persist-credentials: false repository: RunanywhereAI/runanywhere-sdks - ref: b3a2b0147d8c66190c14a53bc04286efd6a4d64a + ref: v0.20.38 path: .deps/runanywhere-sdks - name: Install ninja run: brew install ninja diff --git a/versions.toml b/versions.toml index b085950a..79a3ca67 100644 --- a/versions.toml +++ b/versions.toml @@ -34,7 +34,7 @@ kit_sha256_linux_arm64 = "0f793bbdfd180aa85f1294adf4ce7831cb331df61496bc67ffeb65 # on their own cadence, distinct from the C++ kit above. sdk_package_version = "0.20.37" tools_version = "6.0" -sdk_ci_ref = "b3a2b0147d8c66190c14a53bc04286efd6a4d64a" +sdk_ci_ref = "v0.20.38" [toolchain] # CMake's own floor; CMakeLists.txt declares these and check-versions holds it here. From c17a34863d51218e60b2ff3534a3c0e6ea0871b6 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 08:38:29 +0530 Subject: [PATCH 06/38] sdk: pin the kit to cpp-desktop-v0.20.38-3 (fixed sherpa glibc) The -2 Linux x64 kit bundled a sherpa-onnx-c-api.so requiring GLIBC_2.38, above the 2.35 release floor, so the linux job failed to link. The -3 kits rebuild it on the 22.04 floor (verified: max GLIBC_2.34). Apple kits are rebuilt too; all five SHAs come from the published -3 release and were re-verified against their sidecars. --- versions.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/versions.toml b/versions.toml index 79a3ca67..f5e20efd 100644 --- a/versions.toml +++ b/versions.toml @@ -17,15 +17,15 @@ decisions_contract_sha256 = "2dedb7959a12921efb82fa0e17bfdd0e78ee1cc21c80a1cc438 [sdk] # The published C++ desktop kit this tree builds against; bump the SHAs with it. kit_version = "0.20.38" -kit_release_tag = "cpp-desktop-v0.20.38-2" +kit_release_tag = "cpp-desktop-v0.20.38-3" idl_version = "1.5.0" idl_schema_sha256 = "faca2c22e518f3d41e534399b854bbbc9617d4a76511592a6013a888fdd3cf3d" idl_protoc_version = "35.1" -kit_sha256_macos_arm64 = "413c636b311efa3c2e06e896f5fe25330c2cd339c5c2b1b7209c0e6c6c928a8b" -kit_sha256_windows_x64 = "5bd396b513664487f8ef4f4a2b44013b620605ff0c369a2a51af1188b40c031b" -kit_sha256_windows_arm64 = "fe55474adc1fbf308700a44db42c095a9a74af391fe9fc114f15f5e7523a3fd0" -kit_sha256_linux_x64 = "602eedd836137def5dc819d0eda192ab845f8a1046a7393ca2a3d855b250c978" -kit_sha256_linux_arm64 = "0f793bbdfd180aa85f1294adf4ce7831cb331df61496bc67ffeb65a3acf3a2ab" +kit_sha256_macos_arm64 = "2f6ce6c3942cdf413b32c7df7dcc586389fd1837a24eef03c1a8dcfd8b63f355" +kit_sha256_windows_x64 = "30c2de5c3f76016931b706fbb46c7966e209d0da556db67c62b25c5f757f0aff" +kit_sha256_windows_arm64 = "904bc7a5094aae139b05944646b4a0175272f43506881341031b381054e5ecf3" +kit_sha256_linux_x64 = "6ce52c00e70def9a08c51cf14ba3b6b18db0e67524ceddd2bfda74046ebb20ca" +kit_sha256_linux_arm64 = "d8cd1e34ccabc807502698b6cd54600bdc592649dfe142a51a03ddb6b94a7a4d" [swift] # The Apple MLX host's Swift SDK: sdk_package_version pins swift/Package.swift, From acc72640c95a6d9967405d8b1142e481b89b7943 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 08:41:47 +0530 Subject: [PATCH 07/38] sdk: pin the SDK checkout to the v0.20.38 commit, not the tag The release tags on runanywhere-sdks 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 v0.20.38 resolved to when this pin was made (CodeRabbit security finding on #168). --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- versions.toml | 11 ++++++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e287e507..402a6cef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,7 @@ jobs: with: persist-credentials: false repository: RunanywhereAI/runanywhere-sdks - ref: v0.20.38 + ref: e595580a72452595a4a5b8abc137cad10132ba75 path: .deps/runanywhere-sdks - name: Install ninja run: brew install ninja diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b6c1f8a..512f9f63 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: with: persist-credentials: false repository: RunanywhereAI/runanywhere-sdks - ref: v0.20.38 + ref: e595580a72452595a4a5b8abc137cad10132ba75 path: .deps/runanywhere-sdks - name: Install ninja run: brew install ninja diff --git a/versions.toml b/versions.toml index f5e20efd..3405b3eb 100644 --- a/versions.toml +++ b/versions.toml @@ -30,11 +30,16 @@ kit_sha256_linux_arm64 = "d8cd1e34ccabc807502698b6cd54600bdc592649dfe142a51a03dd [swift] # The Apple MLX host's Swift SDK: sdk_package_version pins swift/Package.swift, # sdk_ci_ref is the complete runanywhere-sdks checkout ref (tag or commit SHA) -# used by ci.yml/release.yml. Keep the leading v on tags. Both move -# on their own cadence, distinct from the C++ kit above. +# 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 +# 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 = "v0.20.38" +sdk_ci_ref = "e595580a72452595a4a5b8abc137cad10132ba75" [toolchain] # CMake's own floor; CMakeLists.txt declares these and check-versions holds it here. From f720bf9518b8c77227b82dd0dbf3267544ba1731 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 08:51:49 +0530 Subject: [PATCH 08/38] decisions: address CodeRabbit findings (transport pick, offline, partial, naming) - model_resolves_locally: a registered non-catalog id must store the Decision category before Auto sends it local; an HF-pulled LLM registered earlier would otherwise reach the decision component and fail at load. Paths stay accepted (register_local_path labels them Language by construction). - --local now bootstraps offline: the SDK's telemetry/auth phase posts anonymous telemetry to the staging backend in development, which broke the documented no-network promise. Registrations (backends, catalog, model paths) still run; downloaded-model discovery still works (verified with a dead base URL). - A non-success decide rc with partial answers now fails instead of printing some answers and exiting 0; every requested question must carry an answer. - The merged clef row carries a backend-neutral name ('Clef Flash 9B (decision)'): the name came from the MLX variant while the pull id and size came from GGUF, so the listing read as MLX while pulling llama.cpp. --- src/bootstrap.rs | 14 ++- src/catalog/catalog.rs | 4 +- src/catalog/model_ref.rs | 6 +- src/commands/cmd_decisions.rs | 89 +++++++++++++++---- .../local__models__list__--all__--json.stdout | 2 +- 5 files changed, 94 insertions(+), 21 deletions(-) diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 7531df43..2ee1808c 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -43,6 +43,12 @@ pub struct GlobalOptions { pub environment: String, pub base_url: String, pub api_key: String, + /// Skip the SDK's network phase entirely (telemetry/auth/device + /// registration). Set by commands that promise to never touch the + /// network — `wally decisions --local` — so the promise holds even in + /// development, where Phase 2 posts anonymous telemetry to the baked + /// staging backend. + pub offline: bool, } /// Validated control-plane connection resolved from GlobalOptions. @@ -990,7 +996,13 @@ pub fn bootstrap(options: &GlobalOptions) -> Result bool { /// A ref that names an existing directory or file on disk. Checked BEFORE the /// HF/URL branch but AFTER the catalog and the registry, so a local path can -/// never shadow a real model id. -fn is_local_path(reference: &str) -> bool { +/// never shadow a real model id. Public so a caller that must tell a path +/// apart from a registered id (the decisions transport picker) applies the +/// same predicate as `resolve` instead of a second, drifting copy. +pub fn is_local_path(reference: &str) -> bool { if reference.is_empty() || is_http_url(reference) || looks_like_hf_ref(reference) { return false; } diff --git a/src/commands/cmd_decisions.rs b/src/commands/cmd_decisions.rs index cd58da1e..354e29ce 100644 --- a/src/commands/cmd_decisions.rs +++ b/src/commands/cmd_decisions.rs @@ -530,21 +530,48 @@ fn transport_from_flags(p: &crate::cli::Parsed) -> Result { /// Whether `reference` is a local model id/alias/path the decision component /// can load. A catalog decision row or a path on disk counts; a hosted model /// id (`eve`) does not. +/// +/// A registered, non-catalog id is not enough on its own: an HF-pulled LLM +/// pulled earlier lives in the same registry, and sending one to the decision +/// component would fail at load instead of scoring. Its stored category must +/// say Decision. Paths are different: `register_local_path` labels them +/// Language by default, so they never pass that check — a path is local by +/// construction and stays accepted. fn model_resolves_locally(reference: &str) -> bool { - match crate::catalog::model_ref::resolve(reference, None) { - Ok(resolved) => { - if resolved.from_catalog { - crate::catalog::find(reference) - .map(|entry| entry.category == v1::ModelCategory::Decision) - .unwrap_or(false) - } else { - // Registered ids (a manifest-restored pull) and local paths - // are local artifacts; a hosted id is not registered here. - true - } - } - Err(_) => false, + if crate::catalog::find(reference).is_some() { + return crate::catalog::find(reference) + .map(|entry| entry.category == v1::ModelCategory::Decision) + .unwrap_or(false); + } + // A path on disk is a local artifact regardless of the registry. + if crate::catalog::model_ref::is_local_path(reference) { + return true; } + // A registered id resolves locally only when the stored category is + // Decision (a manifest-restored `clef-flash-9b` pull, say); an LLM row + // registered here must keep going to the cloud transport. + registered_category(reference) == Some(v1::ModelCategory::Decision) +} + +/// The stored category of a registered model id, if the registry knows it. +fn registered_category(reference: &str) -> Option { + let id = CString::new(reference).ok()?; + let mut out = ProtoBuffer::new(); + // SAFETY: `id` is a valid NUL-terminated string for the call; `out` is a + // freshly-initialised buffer. + let rc = unsafe { + sys::rac_model_registry_get_proto_buffer( + sys::rac_get_model_registry(), + id.as_ptr(), + out.as_mut_ptr(), + ) + }; + if rc != sys::SUCCESS { + return None; + } + parse_proto_buffer::(out) + .ok() + .and_then(|info| v1::ModelCategory::try_from(info.category).ok()) } /// The response shape `create_decisions` returns, rebuilt locally so the @@ -762,7 +789,22 @@ fn run_local(options: &GlobalOptions, request: &contract::DecisionsRequest, json }; let result = match parse_proto_buffer::(out_buffer) { Ok(result) if proto_rc == sys::SUCCESS => result, - Ok(result) if !result.answers.is_empty() => result, + Ok(result) if !result.answers.is_empty() => { + // A non-success rc with answers in hand is a partial result: some + // question was refused (an over-window prompt, a bad span). The + // remaining answers are real but the request as a whole is not, + // and reporting exit 0 would tell a script the missing ids are + // authoritative "no answer". Fail instead, naming the rc. + out::error_line(&format!( + "decision failed: {} ({} of {} questions answered)", + out::describe_result(proto_rc), + result.answers.len(), + request.questions.len() + )); + // SAFETY: handle is the live component created above. + unsafe { sys::rac_decision_component_destroy(handle) }; + return 1; + } Ok(_) => { out::error_line("decision failed: "); // SAFETY: handle is the live component created above. @@ -779,6 +821,16 @@ fn run_local(options: &GlobalOptions, request: &contract::DecisionsRequest, json // SAFETY: handle is the live component created above; not used again. unsafe { sys::rac_decision_component_destroy(handle) }; + // Every requested question must carry an answer, whatever the rc said; + // a missing id is the same partial failure the arm above rejects. + if let Some((missing, _)) = question_views(request) + .into_iter() + .find(|(id, _)| !result.answers.iter().any(|answer| answer.id == *id)) + { + out::error_line(&format!("decision failed: no answer for question '{missing}'")); + return 1; + } + let latency_ms = started.elapsed().as_millis(); let response = local_response(&result, request); if json { @@ -827,7 +879,14 @@ fn run(p: &crate::cli::Parsed, options: &GlobalOptions, json: bool) -> i32 { out::error_line("--no-retry is a cloud-only flag"); return 2; } - return run_local(options, &request, json); + // 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. + let offline = GlobalOptions { + offline: true, + ..options.clone() + }; + return run_local(&offline, &request, json); } // 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. diff --git a/tests/golden/expected/local__models__list__--all__--json.stdout b/tests/golden/expected/local__models__list__--all__--json.stdout index 0cf380cf..0ef0e70c 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":""},{"id":"bonsai-27b","name":"Bonsai 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3803452480,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"bonsai-4b","name":"Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":572270624,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"bonsai-8b","name":"Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1158654496,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"clef-flash-9b","name":"Clef Flash 9B 4-bit (decision, MLX)","modality":"decision","backend":"mlx/llama.cpp","size_bytes":6486448192,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-12b","name":"Gemma 4 12B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":7121861440,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"gemma-4-31b","name":"Gemma 4 31B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":18323733440,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-e2b","name":"Gemma 4 E2B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3106738272,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-e4b","name":"Gemma 4 E4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4977171584,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"id":"lfm2.5-230m","name":"LiquidAI LFM2.5 230M","modality":"llm","backend":"llama.cpp","size_bytes":153406304,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"lfm2.5-350m","name":"LiquidAI LFM2.5 350M","modality":"llm","backend":"llama.cpp","size_bytes":229638144,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"maple-preview","name":"DeepGrove Maple Preview","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4984016416,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-nemotron-nano","name":"NVIDIA Llama 3.1 Nemotron Nano 8B","modality":"llm","backend":"mlx","size_bytes":4534806075,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-llama3.2","name":"Llama 3.2 1B Instruct","modality":"llm","backend":"mlx","size_bytes":712575975,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-nemotron-mini","name":"NVIDIA Nemotron Mini 4B Instruct","modality":"llm","backend":"mlx","size_bytes":2392679103,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"qwen3-0.6b","name":"Qwen3 0.6B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":670040064,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"qwen3.8-27b","name":"Qwen3.8 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":17106775008,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"mlx-ternary-bonsai-27b-2bit","name":"Ternary-Bonsai 27B","modality":"llm","backend":"mlx","size_bytes":8490785104,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"ternary-bonsai-4b","name":"Ternary-Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1137806656,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"ternary-bonsai-8b","name":"Ternary-Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":2310125920,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"smollm2-135m","name":"SmolLM2 135M","modality":"llm","backend":"llama.cpp","size_bytes":100663296,"downloaded":false,"harness_compatible":false,"local_path":""}]} +{"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":""},{"id":"bonsai-27b","name":"Bonsai 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3803452480,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"bonsai-4b","name":"Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":572270624,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"bonsai-8b","name":"Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1158654496,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"clef-flash-9b","name":"Clef Flash 9B (decision)","modality":"decision","backend":"mlx/llama.cpp","size_bytes":6486448192,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-12b","name":"Gemma 4 12B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":7121861440,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"gemma-4-31b","name":"Gemma 4 31B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":18323733440,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-e2b","name":"Gemma 4 E2B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":3106738272,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"gemma-4-e4b","name":"Gemma 4 E4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4977171584,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"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":""},{"id":"lfm2.5-230m","name":"LiquidAI LFM2.5 230M","modality":"llm","backend":"llama.cpp","size_bytes":153406304,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"lfm2.5-350m","name":"LiquidAI LFM2.5 350M","modality":"llm","backend":"llama.cpp","size_bytes":229638144,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"maple-preview","name":"DeepGrove Maple Preview","modality":"llm","backend":"mlx/llama.cpp","size_bytes":4984016416,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-nemotron-nano","name":"NVIDIA Llama 3.1 Nemotron Nano 8B","modality":"llm","backend":"mlx","size_bytes":4534806075,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-llama3.2","name":"Llama 3.2 1B Instruct","modality":"llm","backend":"mlx","size_bytes":712575975,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"mlx-nemotron-mini","name":"NVIDIA Nemotron Mini 4B Instruct","modality":"llm","backend":"mlx","size_bytes":2392679103,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"qwen3-0.6b","name":"Qwen3 0.6B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":670040064,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"qwen3.8-27b","name":"Qwen3.8 27B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":17106775008,"downloaded":false,"harness_compatible":false,"local_path":""},{"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":""},{"id":"mlx-ternary-bonsai-27b-2bit","name":"Ternary-Bonsai 27B","modality":"llm","backend":"mlx","size_bytes":8490785104,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"ternary-bonsai-4b","name":"Ternary-Bonsai 4B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":1137806656,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"ternary-bonsai-8b","name":"Ternary-Bonsai 8B","modality":"llm","backend":"mlx/llama.cpp","size_bytes":2310125920,"downloaded":false,"harness_compatible":false,"local_path":""},{"id":"smollm2-135m","name":"SmolLM2 135M","modality":"llm","backend":"llama.cpp","size_bytes":100663296,"downloaded":false,"harness_compatible":false,"local_path":""}]} From 46ec2471cf63bc51cdc49d1916faa99a3fc69b51 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 09:07:03 +0530 Subject: [PATCH 09/38] decisions: offline pull guard + registry-aware transport probe CodeRabbit round 2: - ensure_model_ready refuses to pull when options.offline is set, so 'wally decisions --local' cannot start a download behind its no-network promise (rc 1, names the pull command to run). - Transport selection now probes the registry through an offline bootstrap (refresh_registry restores manifest entries) instead of reading an empty in-memory registry before bootstrap ran. Catalog rows, paths and known hosted ids still answer without initializing the SDK; a registered non-Decision id keeps going to the hosted transport. --- src/commands/cmd_decisions.rs | 114 +++++++++++++++++++++++++--------- src/commands/model_setup.rs | 12 ++++ 2 files changed, 96 insertions(+), 30 deletions(-) diff --git a/src/commands/cmd_decisions.rs b/src/commands/cmd_decisions.rs index 354e29ce..f5b0179e 100644 --- a/src/commands/cmd_decisions.rs +++ b/src/commands/cmd_decisions.rs @@ -527,30 +527,40 @@ fn transport_from_flags(p: &crate::cli::Parsed) -> Result { } } -/// Whether `reference` is a local model id/alias/path the decision component -/// can load. A catalog decision row or a path on disk counts; a hosted model -/// id (`eve`) does not. +/// Whether `reference` names something the local decision component can load. /// -/// A registered, non-catalog id is not enough on its own: an HF-pulled LLM -/// pulled earlier lives in the same registry, and sending one to the decision -/// component would fail at load instead of scoring. Its stored category must -/// say Decision. Paths are different: `register_local_path` labels them -/// Language by default, so they never pass that check — a path is local by -/// construction and stays accepted. -fn model_resolves_locally(reference: &str) -> bool { - if crate::catalog::find(reference).is_some() { - return crate::catalog::find(reference) - .map(|entry| entry.category == v1::ModelCategory::Decision) - .unwrap_or(false); +/// The catalog and the filesystem answer most references with no SDK +/// involved. A non-catalog, non-path id needs the registry, and a fresh +/// process has an empty in-memory registry until bootstrap restores the +/// manifest entries — so the probe bootstraps in offline mode (no +/// telemetry/auth, no network) before reading the stored category. A +/// registered id is local only when that category is Decision; an HF-pulled +/// LLM registered earlier must keep going to the hosted transport, and a +/// hosted decision id (`eve`) is never registered locally at all. +fn resolve_local_transport(reference: &str, options: &GlobalOptions) -> Result { + if let Some(entry) = crate::catalog::find(reference) { + return Ok(entry.category == v1::ModelCategory::Decision); } // A path on disk is a local artifact regardless of the registry. if crate::catalog::model_ref::is_local_path(reference) { - return true; + return Ok(true); } - // A registered id resolves locally only when the stored category is - // Decision (a manifest-restored `clef-flash-9b` pull, say); an LLM row - // registered here must keep going to the cloud transport. - registered_category(reference) == Some(v1::ModelCategory::Decision) + // Known hosted decision ids are cloud by definition and are never + // registered locally; skip the SDK init the registry read needs so the + // default `eve` invocation keeps its fast path. + if crate::harness::is_decisions_model(reference) { + return Ok(false); + } + let offline = GlobalOptions { + offline: true, + ..options.clone() + }; + bootstrap::bootstrap(&offline) + .map_err(|rc| format!("cannot inspect local models: {}", out::describe_result(rc)))?; + // Manifest-restored pulls come back here. A failed refresh leaves + // whatever is already registered, so the category read is best-effort. + let _ = crate::commands::model_setup::refresh_registry(); + Ok(registered_category(reference) == Some(v1::ModelCategory::Decision)) } /// The stored category of a registered model id, if the registry knows it. @@ -827,7 +837,9 @@ fn run_local(options: &GlobalOptions, request: &contract::DecisionsRequest, json .into_iter() .find(|(id, _)| !result.answers.iter().any(|answer| answer.id == *id)) { - out::error_line(&format!("decision failed: no answer for question '{missing}'")); + out::error_line(&format!( + "decision failed: no answer for question '{missing}'" + )); return 1; } @@ -861,18 +873,28 @@ fn run(p: &crate::cli::Parsed, options: &GlobalOptions, json: bool) -> i32 { } }; let local = match transport { - Transport::Local => { - if !model_resolves_locally(&request.model) { + Transport::Local => match resolve_local_transport(&request.model, options) { + Ok(true) => true, + Ok(false) => { out::error_line(&format!( "--local was given but '{}' does not resolve to a model on this machine", request.model )); return 2; } - true - } + Err(error) => { + out::error_line(&error); + return 1; + } + }, Transport::Cloud => false, - Transport::Auto => model_resolves_locally(&request.model), + Transport::Auto => match resolve_local_transport(&request.model, options) { + Ok(local) => local, + Err(error) => { + out::error_line(&error); + return 1; + } + }, }; if local { if p.is_set("--no-retry") { @@ -1309,11 +1331,43 @@ mod tests { } #[test] - fn a_decision_catalog_row_resolves_locally_and_a_hosted_id_does_not() { - // The engine sees this as an unregistered hosted id; it must not be - // treated as a local artifact (and must not try to load it as one). - assert!(!model_resolves_locally("eve")); - assert!(!model_resolves_locally("definitely-not-a-model")); + fn catalog_decisions_are_local_and_hosted_ids_are_cloud() { + // The registry probe (the unknown-word case) bootstraps the SDK, so + // point it at a throwaway home rather than the real one. Every other + // case below answers before the registry is touched. + let dir = std::env::temp_dir().join(format!( + "wally-decisions-transport-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + let options = GlobalOptions { + home_override: dir.to_string_lossy().into_owned(), + ..GlobalOptions::default() + }; + + // A catalog decision row is local; an LLM row in the same catalog is + // not (it would fail at the decision component). + assert_eq!(resolve_local_transport("clef-flash-9b", &options), Ok(true)); + assert_eq!( + resolve_local_transport("clef-flash-mlx", &options), + Ok(true) + ); + assert_eq!(resolve_local_transport("qwen3-0.6b", &options), Ok(false)); + // A hosted decision id is cloud by definition and must not be treated + // as a local artifact (nor pay for an SDK init to find that out). + assert_eq!(resolve_local_transport("eve", &options), Ok(false)); + // An unknown bare word is not local; the registry probe runs, finds + // nothing, and says so. + assert_eq!( + resolve_local_transport("definitely-not-a-model", &options), + Ok(false) + ); + + let _ = std::fs::remove_dir_all(&dir); } #[test] diff --git a/src/commands/model_setup.rs b/src/commands/model_setup.rs index a9eba308..26c986ed 100644 --- a/src/commands/model_setup.rs +++ b/src/commands/model_setup.rs @@ -140,6 +140,18 @@ pub fn ensure_model_ready( } if !downloaded { + // Offline callers (`wally decisions --local`) promise no network, and + // the pull flow starts a download over the registered HTTP transport. + // Refuse before it begins rather than half-downloading behind the + // promise. + if options.offline { + error_line(&format!( + "model {} is not downloaded, and this command runs offline; \ + pull it first with `wally models pull {}`", + resolved.model_id, resolved.model_id + )); + return Err(1); + } crate::io::output::status_line(&format!( "model {} not downloaded — pulling", resolved.model_id From b6563766db40de4519239f9a7e4d651e0c898ef9 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 09:23:14 +0530 Subject: [PATCH 10/38] decisions: test the MLX row's platform gate instead of assuming macOS catalog::find filters MLX rows by host OS, so off-macOS 'clef-flash-mlx' correctly does not resolve locally. The test asserted Ok(true) everywhere and failed linux/windows/windows-arm64 in CI. --- src/commands/cmd_decisions.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/cmd_decisions.rs b/src/commands/cmd_decisions.rs index f5b0179e..df90a03d 100644 --- a/src/commands/cmd_decisions.rs +++ b/src/commands/cmd_decisions.rs @@ -1352,9 +1352,11 @@ mod tests { // A catalog decision row is local; an LLM row in the same catalog is // not (it would fail at the decision component). assert_eq!(resolve_local_transport("clef-flash-9b", &options), Ok(true)); + // The MLX row is platform-gated (Apple only), so off-macOS the + // catalog filter hides it and it does not resolve locally. assert_eq!( resolve_local_transport("clef-flash-mlx", &options), - Ok(true) + Ok(cfg!(target_os = "macos")) ); assert_eq!(resolve_local_transport("qwen3-0.6b", &options), Ok(false)); // A hosted decision id is cloud by definition and must not be treated From b2720506d685fa4bfee3e3485921587d7e952330 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 10:51:31 +0530 Subject: [PATCH 11/38] decisions: test the GGUF row's engine gate, not just the OS gate clef-flash-9b is filtered by wally_has_llamacpp: the commons-only Windows ARM64 kit has no engine, so the row correctly does not resolve there. The test asserted Ok(true) unconditionally and failed that job. --- src/commands/cmd_decisions.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/commands/cmd_decisions.rs b/src/commands/cmd_decisions.rs index df90a03d..fef27fa6 100644 --- a/src/commands/cmd_decisions.rs +++ b/src/commands/cmd_decisions.rs @@ -1349,9 +1349,14 @@ mod tests { ..GlobalOptions::default() }; - // A catalog decision row is local; an LLM row in the same catalog is - // not (it would fail at the decision component). - assert_eq!(resolve_local_transport("clef-flash-9b", &options), Ok(true)); + // A catalog decision row is local on a build whose linked kit can run + // it; an LLM row in the same catalog is never local (it would fail at + // the decision component). The GGUF row is llama.cpp-gated, so the + // commons-only Windows ARM64 kit filters it out. + assert_eq!( + resolve_local_transport("clef-flash-9b", &options), + Ok(cfg!(wally_has_llamacpp)) + ); // The MLX row is platform-gated (Apple only), so off-macOS the // catalog filter hides it and it does not resolve locally. assert_eq!( From 67bf5f52953d3b7d9d3a734ebf8b2d30bb66a77b Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 12:02:24 +0530 Subject: [PATCH 12/38] kits: bump cpp-desktop pin to v0.20.38-4 for win-arm64 llamacpp --- versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/versions.toml b/versions.toml index 3405b3eb..e7df88f2 100644 --- a/versions.toml +++ b/versions.toml @@ -17,13 +17,13 @@ decisions_contract_sha256 = "2dedb7959a12921efb82fa0e17bfdd0e78ee1cc21c80a1cc438 [sdk] # The published C++ desktop kit this tree builds against; bump the SHAs with it. kit_version = "0.20.38" -kit_release_tag = "cpp-desktop-v0.20.38-3" +kit_release_tag = "cpp-desktop-v0.20.38-4" idl_version = "1.5.0" idl_schema_sha256 = "faca2c22e518f3d41e534399b854bbbc9617d4a76511592a6013a888fdd3cf3d" idl_protoc_version = "35.1" kit_sha256_macos_arm64 = "2f6ce6c3942cdf413b32c7df7dcc586389fd1837a24eef03c1a8dcfd8b63f355" kit_sha256_windows_x64 = "30c2de5c3f76016931b706fbb46c7966e209d0da556db67c62b25c5f757f0aff" -kit_sha256_windows_arm64 = "904bc7a5094aae139b05944646b4a0175272f43506881341031b381054e5ecf3" +kit_sha256_windows_arm64 = "95f63f2c00d8f568bf26e7bbba9b1939430cc0dad9e15123297884224ef31f1e" kit_sha256_linux_x64 = "6ce52c00e70def9a08c51cf14ba3b6b18db0e67524ceddd2bfda74046ebb20ca" kit_sha256_linux_arm64 = "d8cd1e34ccabc807502698b6cd54600bdc592649dfe142a51a03ddb6b94a7a4d" From b9af5c20138ba6380a3e6d43122228caf5b8cb58 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 13:04:17 +0530 Subject: [PATCH 13/38] decisions: ask once for bare-run route, prompt before local pull --- src/commands/cmd_decisions.rs | 166 +++++++++++++++++++++++++++++++++- src/commands/model_setup.rs | 62 +++++++++---- src/config/preferences.rs | 76 +++++++++++++++- 3 files changed, 280 insertions(+), 24 deletions(-) diff --git a/src/commands/cmd_decisions.rs b/src/commands/cmd_decisions.rs index fef27fa6..50f9d5d7 100644 --- a/src/commands/cmd_decisions.rs +++ b/src/commands/cmd_decisions.rs @@ -12,6 +12,7 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; use std::ffi::CString; use std::io::Read; +use std::io::Write as _; use std::time::Instant; use crate::account::decisions_contract as contract; @@ -20,6 +21,7 @@ use crate::bootstrap::{self, GlobalOptions}; use crate::cli::{App, ValueType}; use crate::cli_formatter::{examples_footer, Example}; use crate::commands::model_setup::ensure_model_ready; +use crate::config::preferences::{self, DecisionsRoute}; use crate::io::output as out; use crate::io::proto::{parse_proto_buffer, serialize, v1, ProtoBuffer}; use crate::sys; @@ -167,6 +169,7 @@ fn build_request(p: &crate::cli::Parsed) -> Result String { text.chars().filter(|c| !c.is_control()).collect() } +/// The local decision model a bare `wally decisions` runs: the GGUF row where +/// the linked kit has llama.cpp, the MLX row on Apple builds without it. +/// `None` where this build cannot score anything on-device. +fn default_local_model() -> Option<&'static str> { + if cfg!(wally_has_llamacpp) { + Some("clef-flash-9b") + } else if cfg!(target_os = "macos") { + Some("clef-flash-mlx") + } else { + None + } +} + +/// Parse a route-menu answer: `1` is cloud, `2` is local, anything else +/// (including an empty line) takes the suggestion. +fn parse_route_choice(line: &str, suggested: DecisionsRoute) -> DecisionsRoute { + match line.trim_end_matches(['\n', '\r']).trim() { + "1" => DecisionsRoute::Cloud, + "2" => DecisionsRoute::Local, + _ => suggested, + } +} + +/// Ask once where bare invocations score. Numbered with local suggested +/// (the prompt only runs where a local model exists), Enter takes it. +/// Everything on stderr so `--json` stays clean. +fn prompt_decisions_route(local_id: &str) -> DecisionsRoute { + let signed_in = account::load().map(|c| c.signed_in()).unwrap_or(false); + let login_hint = if signed_in { + String::new() + } else { + " (needs `wally account login` first)".to_string() + }; + eprint!( + "Where should decisions be scored?\n 1) cloud — hosted {} model{}\n 2) local — on-device {}, data stays on this machine <-- suggested\nChoice [1/2, Enter=2]: ", + DEFAULT_MODEL, login_hint, local_id + ); + let _ = std::io::stderr().flush(); + let mut answer = String::new(); + if std::io::stdin().read_line(&mut answer).unwrap_or(0) == 0 { + return DecisionsRoute::Local; + } + parse_route_choice(&answer, DecisionsRoute::Local) +} + /// Which transport `wally decisions` should use. `Auto` is resolved by /// whether `--model` names a local artifact: a decision model on this machine /// is what the command runs; otherwise the hosted console. @@ -858,26 +906,95 @@ fn run_local(options: &GlobalOptions, request: &contract::DecisionsRequest, json } fn run(p: &crate::cli::Parsed, options: &GlobalOptions, json: bool) -> i32 { - let transport = match transport_from_flags(p) { + let mut transport = match transport_from_flags(p) { Ok(transport) => transport, Err(error) => { out::error_line(&error); return 2; } }; - let request = match build_request(p) { + // `--route` saves the default; validated here, persisted after the + // request checks out so a failing invocation never saves a preference. + // It already selects the route, so the per-run flags and a request file + // (which carries its own model) have nothing left to say. + let mut forced_route = None; + if let Some(route) = p.get_str("--route") { + if p.flag("--local") || p.flag("--cloud") || p.is_set("--request") { + out::error_line("--route cannot be combined with --local, --cloud, or --request"); + return 2; + } + match preferences::parse_decisions_route(Some(&route)) { + Some(parsed) => forced_route = Some(parsed), + None => { + out::error_line(&format!( + "decisions route must be 'local' or 'cloud', not '{route}'" + )); + return 2; + } + } + } + let mut request = match build_request(p) { Ok(request) => request, Err(error) => { out::error_line(&error); return 2; } }; + if let Some(route) = forced_route { + if let Err(error) = preferences::set_decisions_route(route.name()) { + out::error_line(&error); + return 2; + } + out::status_line(&format!("saved decisions route: {}", route.name())); + } + // No flags and no model is the first-run shape: follow the saved route, + // ask once when there is none, and stay on today's cloud default where + // no answer can come (a pipe) or nothing local can run. + if transport == Transport::Auto && !p.is_set("--model") && !p.is_set("--request") { + let route = match forced_route.or_else(preferences::saved_decisions_route) { + Some(saved) => { + out::status_line(&format!("using saved decisions route: {}", saved.name())); + saved + } + None if !term::stdin_is_tty() => DecisionsRoute::Cloud, + None => match default_local_model() { + None => DecisionsRoute::Cloud, + Some(local_id) => { + let choice = prompt_decisions_route(local_id); + match preferences::set_decisions_route(match choice { + DecisionsRoute::Local => "local", + DecisionsRoute::Cloud => "cloud", + }) { + Ok(_) => out::status_line("saved as your decisions default"), + Err(error) => out::status_line(&format!( + "warning: choice not saved ({error}); continuing anyway" + )), + } + choice + } + }, + }; + if route == DecisionsRoute::Local { + match default_local_model() { + Some(local_id) => { + request.model = local_id.to_string(); + transport = Transport::Local; + } + None => { + out::error_line( + "saved decisions route is 'local' but this build cannot score on-device", + ); + return 2; + } + } + } + } let local = match transport { Transport::Local => match resolve_local_transport(&request.model, options) { Ok(true) => true, Ok(false) => { out::error_line(&format!( - "--local was given but '{}' does not resolve to a model on this machine", + "local scoring was selected but '{}' does not resolve to a model on this machine", request.model )); return 2; @@ -1045,6 +1162,11 @@ pub fn register_decisions(app: &mut App) { "--cloud", "Score on your account's hosted endpoint (never a local model)", ); + cmd.add_option( + "--route", + ValueType::Text, + "Save 'local' or 'cloud' as the default route (used when no flags are given)", + ); cmd.add_flag("--no-retry", "Do not retry HTTP 429 or 503"); cmd.footer(&examples_footer(&[ Example::new( @@ -1095,6 +1217,44 @@ mod tests { assert!(crate::harness::is_decisions_model(DEFAULT_MODEL)); } + #[test] + fn default_local_model_matches_what_this_build_can_run() { + // Mirrors the catalog gating the transport test asserts on: GGUF + // where llama.cpp is linked, MLX on Apple builds without it, and no + // local model where neither exists. + if cfg!(wally_has_llamacpp) { + assert_eq!(default_local_model(), Some("clef-flash-9b")); + } else if cfg!(target_os = "macos") { + assert_eq!(default_local_model(), Some("clef-flash-mlx")); + } else { + assert_eq!(default_local_model(), None); + } + } + + #[test] + fn route_menu_numbers_are_stable_and_blank_takes_the_suggestion() { + assert_eq!( + parse_route_choice("1", DecisionsRoute::Local), + DecisionsRoute::Cloud + ); + assert_eq!( + parse_route_choice("2", DecisionsRoute::Cloud), + DecisionsRoute::Local + ); + assert_eq!( + parse_route_choice("", DecisionsRoute::Local), + DecisionsRoute::Local + ); + assert_eq!( + parse_route_choice(" \n", DecisionsRoute::Cloud), + DecisionsRoute::Cloud + ); + assert_eq!( + parse_route_choice("3", DecisionsRoute::Local), + DecisionsRoute::Local + ); + } + #[test] fn request_file_with_an_unknown_field_is_named_at_every_depth() { let value = serde_json::json!({ diff --git a/src/commands/model_setup.rs b/src/commands/model_setup.rs index 26c986ed..3294afbe 100644 --- a/src/commands/model_setup.rs +++ b/src/commands/model_setup.rs @@ -10,9 +10,11 @@ use std::ffi::CString; use crate::bootstrap::GlobalOptions; use crate::catalog::model_ref; -use crate::io::output::error_line; +use crate::harness; +use crate::io::output::{error_line, status_line}; use crate::io::proto::{self, parse_proto_buffer, v1, ProtoBuffer}; use crate::sys; +use crate::util::term; use super::cmd_pull::pull_model_flow; @@ -106,7 +108,7 @@ pub fn ensure_model_ready( // Link any on-disk artifacts before deciding whether to pull. if let Err(error) = refresh_registry() { - crate::io::output::status_line(&format!("warning: registry refresh failed: {error}")); + status_line(&format!("warning: registry refresh failed: {error}")); } // Display name (best effort) + downloaded check. @@ -140,25 +142,47 @@ pub fn ensure_model_ready( } if !downloaded { - // Offline callers (`wally decisions --local`) promise no network, and - // the pull flow starts a download over the registered HTTP transport. - // Refuse before it begins rather than half-downloading behind the - // promise. + // Offline callers (`wally decisions --local`) promise no network + // during scoring, but an explicit pull is the caller's own download: + // offer it on a terminal exactly like the harness commands do, then + // run it with the network back on. A "no" (or no terminal, where no + // answer can come) keeps the old refusal naming the pull command. if options.offline { - error_line(&format!( - "model {} is not downloaded, and this command runs offline; \ - pull it first with `wally models pull {}`", - resolved.model_id, resolved.model_id + if term::stdin_is_tty() + && harness::confirm(&format!( + "model {} is not downloaded. Pull it now? [y/N] ", + resolved.model_id + )) + { + let online = GlobalOptions { + offline: false, + ..options.clone() + }; + status_line(&format!( + "model {} not downloaded — pulling", + resolved.model_id + )); + let pull_code = pull_model_flow(&online, &resolved.model_id); + if pull_code != 0 { + return Err(pull_code); + } + } else { + error_line(&format!( + "model {} is not downloaded, and this command runs offline; \ + pull it first with `wally models pull {}`", + resolved.model_id, resolved.model_id + )); + return Err(1); + } + } else { + status_line(&format!( + "model {} not downloaded — pulling", + resolved.model_id )); - return Err(1); - } - crate::io::output::status_line(&format!( - "model {} not downloaded — pulling", - resolved.model_id - )); - let pull_code = pull_model_flow(options, &resolved.model_id); - if pull_code != 0 { - return Err(pull_code); + let pull_code = pull_model_flow(options, &resolved.model_id); + if pull_code != 0 { + return Err(pull_code); + } } } diff --git a/src/config/preferences.rs b/src/config/preferences.rs index 922637ca..de1ee2ea 100644 --- a/src/config/preferences.rs +++ b/src/config/preferences.rs @@ -1,7 +1,8 @@ //! Non-secret per-profile CLI preferences (port of src/config/preferences.cpp). //! //! Distinct from the credential store (`account`), which is the secret file at -//! mode 0600. The only key today is the default model. Resolution precedence, +//! mode 0600. Two keys today: the default model, and the decisions route +//! (`wally decisions` without transport flags). Resolution precedence, //! highest first: //! 1. an explicit model the reader passed //! 2. the `WALLY_DEFAULT_MODEL` environment variable (a one-off shell override) @@ -16,6 +17,7 @@ use crate::io::output; const FILE_NAME: &str = "preferences.json"; const DEFAULT_MODEL_KEY: &str = "default_model"; +const DECISIONS_ROUTE_KEY: &str = "decisions_route"; /// {ProfileDirectory}/preferences.json, or empty when $HOME is unresolvable. pub fn preferences_path() -> String { @@ -188,10 +190,60 @@ pub fn clear_default_model() -> Result<(), String> { write_file(&document) } +/// Where a bare `wally decisions` (no `--local`/`--cloud`, no `--model`) +/// scores. Saved the first time the command asks, followed silently after. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum DecisionsRoute { + /// score on the hosted console (`eve`) + #[default] + Cloud, + /// score on this machine (the platform's local decision model) + Local, +} + +impl DecisionsRoute { + pub(crate) fn name(self) -> &'static str { + match self { + DecisionsRoute::Local => "local", + DecisionsRoute::Cloud => "cloud", + } + } +} + +/// The file value for a route, if it names one. Anything else (missing key, +/// another string) is no saved route, never an error. +pub(crate) fn parse_decisions_route(raw: Option<&str>) -> Option { + match raw { + Some("local") => Some(DecisionsRoute::Local), + Some("cloud") => Some(DecisionsRoute::Cloud), + _ => None, + } +} + +/// The saved decisions route, if the file names one. +pub fn saved_decisions_route() -> Option { + parse_decisions_route(read_file().get(DECISIONS_ROUTE_KEY)?.as_str()) +} + +/// Persist the decisions route. Rejects anything but `local`/`cloud` so a +/// typo never becomes a silent default. +pub fn set_decisions_route(route: &str) -> Result { + let parsed = parse_decisions_route(Some(route)) + .ok_or_else(|| format!("decisions route must be 'local' or 'cloud', not '{route}'"))?; + let mut document = read_file(); + if let Value::Object(map) = &mut document { + map.insert( + DECISIONS_ROUTE_KEY.to_string(), + Value::String(route.to_string()), + ); + } + write_file(&document)?; + Ok(parsed) +} + #[cfg(test)] mod write_temp_file_tests { use super::write_temp_file; - // An open failure (temp file cannot even be created) must produce // "cannot write {temp}", distinct from a write/flush failure. #[test] @@ -214,3 +266,23 @@ mod write_temp_file_tests { assert_eq!(std::fs::read_to_string(&temp).unwrap(), "{}\n"); } } + +#[cfg(test)] +mod decisions_route_tests { + use super::{parse_decisions_route, DecisionsRoute}; + + #[test] + fn only_local_and_cloud_name_a_route() { + assert_eq!( + parse_decisions_route(Some("local")), + Some(DecisionsRoute::Local) + ); + assert_eq!( + parse_decisions_route(Some("cloud")), + Some(DecisionsRoute::Cloud) + ); + assert_eq!(parse_decisions_route(Some("eve")), None); + assert_eq!(parse_decisions_route(Some("")), None); + assert_eq!(parse_decisions_route(None), None); + } +} From 21918c20a68330f8142c36b04a7d61d20883c4a6 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 13:10:03 +0530 Subject: [PATCH 14/38] decisions: note the route prompt is one-time --- src/commands/cmd_decisions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/cmd_decisions.rs b/src/commands/cmd_decisions.rs index 50f9d5d7..0a5b0b1a 100644 --- a/src/commands/cmd_decisions.rs +++ b/src/commands/cmd_decisions.rs @@ -543,7 +543,7 @@ fn prompt_decisions_route(local_id: &str) -> DecisionsRoute { " (needs `wally account login` first)".to_string() }; eprint!( - "Where should decisions be scored?\n 1) cloud — hosted {} model{}\n 2) local — on-device {}, data stays on this machine <-- suggested\nChoice [1/2, Enter=2]: ", + "Where should decisions be scored? (one-time question — your choice is saved as the default)\n 1) cloud — hosted {} model{}\n 2) local — on-device {}, data stays on this machine <-- suggested\nChoice [1/2, Enter=2]: ", DEFAULT_MODEL, login_hint, local_id ); let _ = std::io::stderr().flush(); From 252332864b4a68427a7c1a29e3f27442bd2da250 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 13:11:19 +0530 Subject: [PATCH 15/38] decisions: plain wording for route prompt, no em dashes --- src/commands/cmd_decisions.rs | 2 +- src/commands/model_setup.rs | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/commands/cmd_decisions.rs b/src/commands/cmd_decisions.rs index 0a5b0b1a..69de9a11 100644 --- a/src/commands/cmd_decisions.rs +++ b/src/commands/cmd_decisions.rs @@ -543,7 +543,7 @@ fn prompt_decisions_route(local_id: &str) -> DecisionsRoute { " (needs `wally account login` first)".to_string() }; eprint!( - "Where should decisions be scored? (one-time question — your choice is saved as the default)\n 1) cloud — hosted {} model{}\n 2) local — on-device {}, data stays on this machine <-- suggested\nChoice [1/2, Enter=2]: ", + "How should decisions be scored? One-time question, your answer is saved as the default.\n 1) cloud: hosted {} model{}\n 2) local: on-device {}, data stays on this machine <-- suggested\nChoice [1/2, Enter=2]: ", DEFAULT_MODEL, login_hint, local_id ); let _ = std::io::stderr().flush(); diff --git a/src/commands/model_setup.rs b/src/commands/model_setup.rs index 3294afbe..bbade3b8 100644 --- a/src/commands/model_setup.rs +++ b/src/commands/model_setup.rs @@ -158,10 +158,7 @@ pub fn ensure_model_ready( offline: false, ..options.clone() }; - status_line(&format!( - "model {} not downloaded — pulling", - resolved.model_id - )); + status_line(&format!("pulling model {} now", resolved.model_id)); let pull_code = pull_model_flow(&online, &resolved.model_id); if pull_code != 0 { return Err(pull_code); From 1847c84f28f11ae0ba40bb0d8f9421d7748c56a8 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 13:13:33 +0530 Subject: [PATCH 16/38] decisions: drop suggested marker from route menu --- src/commands/cmd_decisions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/cmd_decisions.rs b/src/commands/cmd_decisions.rs index 69de9a11..afc19b1e 100644 --- a/src/commands/cmd_decisions.rs +++ b/src/commands/cmd_decisions.rs @@ -543,7 +543,7 @@ fn prompt_decisions_route(local_id: &str) -> DecisionsRoute { " (needs `wally account login` first)".to_string() }; eprint!( - "How should decisions be scored? One-time question, your answer is saved as the default.\n 1) cloud: hosted {} model{}\n 2) local: on-device {}, data stays on this machine <-- suggested\nChoice [1/2, Enter=2]: ", + "How should decisions be scored? One-time question, your answer is saved as the default.\n 1) cloud: hosted {} model{}\n 2) local: on-device {}, data stays on this machine\nChoice [1/2, Enter=2]: ", DEFAULT_MODEL, login_hint, local_id ); let _ = std::io::stderr().flush(); From 4a27506c82e125907de8721814a866a891311506 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 14:48:33 +0530 Subject: [PATCH 17/38] release: dry_run input that builds everything without shipping --- .github/workflows/release.yml | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 512f9f63..034ea998 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,11 @@ on: version: description: 'Version to release (e.g. 0.5.0 — no leading v)' required: true + dry_run: + description: 'Build, package and verify everything without creating a tag or release (version must match versions.toml)' + required: false + default: false + type: boolean permissions: contents: write @@ -21,8 +26,37 @@ env: # production console defaults in credentials.cpp). The configure→build→package # sequence lives in .github/actions/build-wally; e2e runs on the binary it # produced, so the bottle is built exactly once. +# +# Dry run: dispatch with dry_run=true and the current versions.toml version. +# Everything builds, packages and verifies, but the final release step is +# skipped, so no tag and no GitHub Release are created. jobs: + validate-dispatch: + # Fails fast on a bad manual input instead of burning five platform + # builds. Always runs so `needs` below never sees a skipped job on push. + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Validate dispatch inputs + if: ${{ github.event_name == 'workflow_dispatch' }} + env: + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::version must be X.Y.Z (no leading v); got '$VERSION'" >&2 + exit 1 + fi + pinned="$(awk '/^\[product\]/{p=1} p && /^version = "/{gsub(/"/, "", $3); print $3; exit}' versions.toml)" + if [[ "$DRY_RUN" == "true" && "$VERSION" != "$pinned" ]]; then + echo "::error::dry_run needs version=$pinned (versions.toml [product]); got '$VERSION'" >&2 + exit 1 + fi macos: + needs: [validate-dispatch] # SDK Package.swift is swift-tools-version 6.2 (Xcode 26). runs-on: macos-26 timeout-minutes: 90 @@ -93,6 +127,7 @@ jobs: path: dist/wally-*-macos-arm64*.tar.gz* windows-arm64: + needs: [validate-dispatch] runs-on: windows-11-arm timeout-minutes: 60 strategy: @@ -167,6 +202,7 @@ jobs: if-no-files-found: error windows: + needs: [validate-dispatch] runs-on: windows-2022 timeout-minutes: 60 strategy: @@ -239,6 +275,7 @@ jobs: if-no-files-found: error linux: + needs: [validate-dispatch] # Release compatibility floor. Ubuntu 24.04 produced a bottle requiring # GLIBC_2.38 and GLIBCXX_3.4.31/32, which fails on Ubuntu 22.04. runs-on: ubuntu-22.04 @@ -316,6 +353,7 @@ jobs: path: dist/wally-*-linux-x86_64*.tar.gz* linux-arm64: + needs: [validate-dispatch] # Built on the arm64 compat floor (glibc 2.34) so wally's own code stays # low; the kit's Sherpa prebuilt (glibc 2.38) sets the bottle floor. The # arm64 kit's cpp-httplib (zstd, brotli) and libarchive (bz2) reference @@ -455,6 +493,10 @@ jobs: ( cd artifacts && shasum -a 256 "$f" > "${f}.sha256" ) done - name: Create the release + # Dry runs stop here: bottles and metadata stay workflow artifacts, + # no tag and no GitHub Release. On tag pushes `inputs` is empty, so + # this still runs for real releases. + if: ${{ inputs.dry_run != true }} uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.event_name == 'push' && github.ref_name || format('v{0}', github.event.inputs.version) }} From edb1d71953704997637a3be2a007897a019bd714 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 15:20:04 +0530 Subject: [PATCH 18/38] decisions: update golden help for --route --- swift/Package.resolved | 9 ++++----- tests/golden/README.md | 4 ++++ tests/golden/expected/help__decide.stdout | 2 ++ tests/golden/expected/help__decisions.stdout | 2 ++ 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/swift/Package.resolved b/swift/Package.resolved index de88fa2c..5c40fb52 100644 --- a/swift/Package.resolved +++ b/swift/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c1359592c19f4c935e1fdc11e97cd5b4f89b7e79ebade8bf6cdcc7e29654aad7", + "originHash" : "26d89de974a3ced470680c8d13ee9cb1391d889c1ef9b01e915d85e3c546459d", "pins" : [ { "identity" : "devicekit", @@ -33,8 +33,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/RunanywhereAI/mlx-audio-swift.git", "state" : { - "revision" : "661fb564f013c8e396cb803899eead73bd299ef6", - "version" : "0.1.5" + "revision" : "ecc772d3c433d8b7967d423f2ea35995085c4d1c" } }, { @@ -51,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/RunanywhereAI/mlx-swift-lm.git", "state" : { - "revision" : "5270d2957d828fa8e65121ab5386c115060522bd", - "version" : "3.31.5" + "revision" : "6166b1bb1ea96652f37231d09fb3f475fcfa3a25", + "version" : "3.31.8" } }, { diff --git a/tests/golden/README.md b/tests/golden/README.md index 1e9d0d40..b3a1ca27 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -59,6 +59,10 @@ decision model" (it named the retired Qwev model) and its `--model` default is updated for those two strings only, and the corpus replays byte-exact against the Rust binary. +`wally decisions` gained a `--route` option saving `local` or `cloud` as the +default route for flag-less invocations. `help__decisions` and `help__decide` +were updated for those two rows only; nothing else about them changed. + Re-capture from a binary (only when behaviour changes on purpose): python3 scripts/test/golden-capture.py --binary build/wally --out tests/golden --cases tests/golden/cases.json diff --git a/tests/golden/expected/help__decide.stdout b/tests/golden/expected/help__decide.stdout index ccd334a6..119aa079 100644 --- a/tests/golden/expected/help__decide.stdout +++ b/tests/golden/expected/help__decide.stdout @@ -18,6 +18,8 @@ Options: (never the network) --cloud Score on your account's hosted endpoint (never a local model) + --route TEXT Save 'local' or 'cloud' as the default route (used + when no flags are given) --no-retry Do not retry HTTP 429 or 503 Examples: diff --git a/tests/golden/expected/help__decisions.stdout b/tests/golden/expected/help__decisions.stdout index ccd334a6..119aa079 100644 --- a/tests/golden/expected/help__decisions.stdout +++ b/tests/golden/expected/help__decisions.stdout @@ -18,6 +18,8 @@ Options: (never the network) --cloud Score on your account's hosted endpoint (never a local model) + --route TEXT Save 'local' or 'cloud' as the default route (used + when no flags are given) --no-retry Do not retry HTTP 429 or 503 Examples: From 18e5f016a7cc44a342344e5b78cf596ebde6585c Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 17:36:55 +0530 Subject: [PATCH 19/38] run: Ctrl-C at prompt reprompts instead of exiting --- src/commands/cmd_run.rs | 1 + src/repl/repl.rs | 91 ++++++++++++++++++++++++++++++++++------- 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/src/commands/cmd_run.rs b/src/commands/cmd_run.rs index 6fbadab6..42512bf3 100644 --- a/src/commands/cmd_run.rs +++ b/src/commands/cmd_run.rs @@ -732,6 +732,7 @@ fn print_repl_help() { status_line(" /set max-output-tokens set the generation budget"); status_line(" /show show current settings"); status_line(" /bye exit (also Ctrl-D)"); + status_line("Ctrl-C cancels the current turn"); status_line("note: turns are independent — no conversation memory yet"); } diff --git a/src/repl/repl.rs b/src/repl/repl.rs index c9c60115..9cbe6247 100644 --- a/src/repl/repl.rs +++ b/src/repl/repl.rs @@ -21,6 +21,13 @@ pub struct LineEditor { history_path: String, } +/// Set while a prompt read is in flight and Ctrl-C arrives: the read then +/// resolves to an empty line (the REPL reprompts) instead of the process +/// dying with exit 130, which is what Ctrl-C at the `»` prompt used to do. +#[cfg(windows)] +static PROMPT_INTERRUPTED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + /// MSVC's / decode a narrow std::string through the ANSI /// code page in the C++; Rust's std::path is always UTF-8-clean, so no /// equivalent conversion is needed here. @@ -59,13 +66,13 @@ impl LineEditor { } } - /// `None` on EOF (Ctrl-D). Empty lines are returned as empty strings. + /// `None` on EOF (Ctrl-D). Ctrl-C cancels the line and reprompts, so it + /// resolves to an empty line; empty lines are returned as empty strings. pub fn read_line(&mut self, prompt: &str) -> Option { match self.editor.readline(prompt) { Ok(line) => Some(line), - // EOF / Ctrl-D and Ctrl-C both return NULL from linenoise; mirror - // that here so both keys end the REPL the same way. - Err(ReadlineError::Eof) | Err(ReadlineError::Interrupted) => None, + Err(ReadlineError::Eof) => None, + Err(ReadlineError::Interrupted) => Some(String::new()), Err(_) => None, } } @@ -99,22 +106,28 @@ impl LineEditor { } } - /// `None` on EOF (Ctrl-D). Empty lines are returned as empty strings. + /// `None` on EOF (Ctrl-D). Ctrl-C cancels the line and reprompts: the + /// guard below swallows it for the duration of the read (otherwise the + /// process dies), and the flag turns it into an empty line. A read error + /// with no interrupt is still EOF. pub fn read_line(&mut self, prompt: &str) -> Option { use std::io::Write; eprint!("{prompt}"); let _ = std::io::stderr().flush(); + let _prompt_guard = crate::util::interrupt::on_interrupt(|| { + PROMPT_INTERRUPTED.store(true, std::sync::atomic::Ordering::SeqCst); + }); + PROMPT_INTERRUPTED.store(false, std::sync::atomic::Ordering::SeqCst); let mut line = String::new(); - match std::io::stdin().read_line(&mut line) { - Ok(0) => None, - Ok(_) => { - while line.ends_with('\n') || line.ends_with('\r') { - line.pop(); - } - Some(line) - } - Err(_) => None, + let read = std::io::stdin().read_line(&mut line); + while line.ends_with('\n') || line.ends_with('\r') { + line.pop(); } + resolve_windows_read( + PROMPT_INTERRUPTED.load(std::sync::atomic::Ordering::SeqCst), + read, + line, + ) } /// Record a line in history (skips empties/duplicates of last entry). @@ -131,3 +144,53 @@ impl LineEditor { } } } + +/// What a Windows prompt read resolves to. An interrupt (Ctrl-C) wins over +/// whatever the read did, even a half-typed line: it becomes the empty line +/// the REPL loop reprompts on. A clean EOF ends the session; anything else +/// is the typed line, and a bare read error exits like EOF before it. +#[cfg(windows)] +fn resolve_windows_read( + interrupted: bool, + read: std::io::Result, + line: String, +) -> Option { + if interrupted { + return Some(String::new()); + } + match read { + Ok(0) => None, + Ok(_) => Some(line), + Err(_) => None, + } +} + +#[cfg(all(test, windows))] +mod tests { + use super::resolve_windows_read; + + #[test] + fn interrupt_discards_even_a_half_typed_line() { + assert_eq!( + resolve_windows_read(true, Ok(4), "half".to_string()), + Some(String::new()) + ); + assert_eq!( + resolve_windows_read(true, Err(std::io::Error::other("gone")), String::new()), + Some(String::new()) + ); + } + + #[test] + fn clean_reads_keep_prior_meaning() { + assert_eq!( + resolve_windows_read(false, Ok(4), "hi".to_string()), + Some("hi".to_string()) + ); + assert_eq!(resolve_windows_read(false, Ok(0), String::new()), None); + assert_eq!( + resolve_windows_read(false, Err(std::io::Error::other("gone")), String::new()), + None + ); + } +} From f1c8a0891614c9783093572dd38d330be287b9ae Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 18:45:05 +0530 Subject: [PATCH 20/38] kits: re-pin -4 arm64 kits with SVE disabled --- versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/versions.toml b/versions.toml index e7df88f2..1fa6c547 100644 --- a/versions.toml +++ b/versions.toml @@ -23,9 +23,9 @@ idl_schema_sha256 = "faca2c22e518f3d41e534399b854bbbc9617d4a76511592a6013a888fdd idl_protoc_version = "35.1" kit_sha256_macos_arm64 = "2f6ce6c3942cdf413b32c7df7dcc586389fd1837a24eef03c1a8dcfd8b63f355" kit_sha256_windows_x64 = "30c2de5c3f76016931b706fbb46c7966e209d0da556db67c62b25c5f757f0aff" -kit_sha256_windows_arm64 = "95f63f2c00d8f568bf26e7bbba9b1939430cc0dad9e15123297884224ef31f1e" +kit_sha256_windows_arm64 = "51e227a0392cc93e6e6f908aeac1f6636ecb0b2b4cb8483788dde4895" kit_sha256_linux_x64 = "6ce52c00e70def9a08c51cf14ba3b6b18db0e67524ceddd2bfda74046ebb20ca" -kit_sha256_linux_arm64 = "d8cd1e34ccabc807502698b6cd54600bdc592649dfe142a51a03ddb6b94a7a4d" +kit_sha256_linux_arm64 = "a584652366c23eb63e43ee517aa042e57948729b06bb7e5cd22898759c61a3fa" [swift] # The Apple MLX host's Swift SDK: sdk_package_version pins swift/Package.swift, From e2dea1142bb5bd24cbdd8d7ba9a943686cf81c97 Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 19:11:22 +0530 Subject: [PATCH 21/38] kits: re-tar windows-arm64, log download size on mismatch --- scripts/build/fetch-kit.sh | 3 +++ versions.toml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/build/fetch-kit.sh b/scripts/build/fetch-kit.sh index ee58184b..55f1134d 100755 --- a/scripts/build/fetch-kit.sh +++ b/scripts/build/fetch-kit.sh @@ -53,6 +53,9 @@ if ! gh release download "$RELEASE_TAG" \ fi file="${dl}/${asset}" +if command -v stat >/dev/null 2>&1; then + echo "downloaded ${asset}: $(stat -c%s "$file" 2>/dev/null || stat -f%z "$file") bytes" >&2 +fi if command -v shasum >/dev/null 2>&1; then actual="$(shasum -a 256 "$file" | awk '{print $1}')" else diff --git a/versions.toml b/versions.toml index 1fa6c547..6a15b48a 100644 --- a/versions.toml +++ b/versions.toml @@ -23,7 +23,7 @@ idl_schema_sha256 = "faca2c22e518f3d41e534399b854bbbc9617d4a76511592a6013a888fdd idl_protoc_version = "35.1" kit_sha256_macos_arm64 = "2f6ce6c3942cdf413b32c7df7dcc586389fd1837a24eef03c1a8dcfd8b63f355" kit_sha256_windows_x64 = "30c2de5c3f76016931b706fbb46c7966e209d0da556db67c62b25c5f757f0aff" -kit_sha256_windows_arm64 = "51e227a0392cc93e6e6f908aeac1f6636ecb0b2b4cb8483788dde4895" +kit_sha256_windows_arm64 = "cb44e4f7a3b4211e202d1f5f463b45690cde632c149bb5fc45a580785af811ca" kit_sha256_linux_x64 = "6ce52c00e70def9a08c51cf14ba3b6b18db0e67524ceddd2bfda74046ebb20ca" kit_sha256_linux_arm64 = "a584652366c23eb63e43ee517aa042e57948729b06bb7e5cd22898759c61a3fa" From 756e7185009f73929476f7d1055b7dc293a6d06a Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Mon, 5 Oct 2026 16:30:21 +0530 Subject: [PATCH 22/38] serve: local decision models over HTTP (POST /v1/decisions) --- src/commands/cmd_decisions.rs | 134 ++++++++------- src/commands/cmd_serve.rs | 295 ++++++++++++++++++++++++++++++++++ 2 files changed, 371 insertions(+), 58 deletions(-) diff --git a/src/commands/cmd_decisions.rs b/src/commands/cmd_decisions.rs index afc19b1e..ee95750e 100644 --- a/src/commands/cmd_decisions.rs +++ b/src/commands/cmd_decisions.rs @@ -78,7 +78,7 @@ fn validate_model_shape(model: &str) -> Result<(), String> { Ok(()) } -fn validate(request: &contract::DecisionsRequest) -> Result<(), String> { +pub(crate) fn validate(request: &contract::DecisionsRequest) -> Result<(), String> { nonblank(&request.input, "input", 1_000_000)?; nonblank(&request.model, "model", 128)?; if !(1..=128).contains(&request.questions.len()) { @@ -354,7 +354,7 @@ fn collect_unknown_fields( /// Fields of a `--request` document that the contract's DecisionsRequest /// does not define, as dotted paths (`questions[0].opitons`). -fn unknown_request_fields(value: &serde_json::Value) -> Vec { +pub(crate) fn unknown_request_fields(value: &serde_json::Value) -> Vec { let Ok(root) = serde_json::from_str::(CONTRACT) else { return Vec::new(); }; @@ -585,7 +585,10 @@ fn transport_from_flags(p: &crate::cli::Parsed) -> Result { /// registered id is local only when that category is Decision; an HF-pulled /// LLM registered earlier must keep going to the hosted transport, and a /// hosted decision id (`eve`) is never registered locally at all. -fn resolve_local_transport(reference: &str, options: &GlobalOptions) -> Result { +pub(crate) fn resolve_local_transport( + reference: &str, + options: &GlobalOptions, +) -> Result { if let Some(entry) = crate::catalog::find(reference) { return Ok(entry.category == v1::ModelCategory::Decision); } @@ -778,68 +781,73 @@ fn local_request(request: &contract::DecisionsRequest) -> v1::DecisionRequest { /// Score through the local component: resolve the model, load it through the /// lifecycle (auto-pull when missing), drive the decision component, and hand /// the mapped response back to the shared renderer. -fn run_local(options: &GlobalOptions, request: &contract::DecisionsRequest, json: bool) -> i32 { - if bootstrap::bootstrap(options).is_err() { - return 1; - } - if let Err(error) = crate::commands::model_setup::refresh_registry() { - out::status_line(&format!("warning: registry refresh failed: {error}")); - } +/// A created, model-loaded decision component. Dropping unloads nothing +/// extra (unload is per-model on reload); destroy always runs exactly once. +pub(crate) struct LoadedDecisions { + handle: sys::rac_handle_t, +} - let model = match ensure_model_ready(options, &request.model) { - Ok(model) => model, - Err(exit_code) => return exit_code, - }; +impl Drop for LoadedDecisions { + fn drop(&mut self) { + // SAFETY: handle came from rac_decision_component_create and is + // destroyed exactly once here; nothing else touches it afterwards. + unsafe { sys::rac_decision_component_destroy(self.handle) }; + } +} +/// Create the component and load `model` into it. Error strings match what +/// run_local printed inline, so both callers report identically. +pub(crate) fn load_decisions_component( + model: &crate::commands::model_setup::ResolvedModelPaths, +) -> Result { let mut handle: sys::rac_handle_t = std::ptr::null_mut(); // SAFETY: `handle` is a valid out-pointer for the duration of the call. let rc = unsafe { sys::rac_decision_component_create(&mut handle) }; if rc != sys::SUCCESS || handle.is_null() { - out::error_line("failed to create decision component"); - return 1; + return Err("failed to create decision component".to_string()); } + let loaded = LoadedDecisions { handle }; let model_path = CString::new(model.primary_path.as_str()); let model_id = CString::new(model.model_id.as_str()); let model_name = CString::new(model.display_name.as_str()); let (model_path, model_id, model_name) = match (model_path, model_id, model_name) { (Ok(path), Ok(id), Ok(name)) => (path, id, name), - _ => { - out::error_line("model path, id or name contains a NUL byte"); - // SAFETY: handle was just created above. - unsafe { sys::rac_decision_component_destroy(handle) }; - return 1; - } + _ => return Err("model path, id or name contains a NUL byte".to_string()), }; - // SAFETY: handle is the live component created above; the three C strings - // outlive this call. + // SAFETY: handle is live; the three C strings outlive this call. let rc = unsafe { sys::rac_decision_component_load_model( - handle, + loaded.handle, model_path.as_ptr(), model_id.as_ptr(), model_name.as_ptr(), ) }; if rc != sys::SUCCESS { - out::error_line(&format!( + return Err(format!( "failed to load decision model: {}", out::describe_result(rc) )); - // SAFETY: handle is the live component created above. - unsafe { sys::rac_decision_component_destroy(handle) }; - return 1; } + Ok(loaded) +} +/// Score one request on an already-loaded component. Error strings match +/// what run_local printed inline (partial results fail naming the rc, a +/// missing answer id fails naming the id). +pub(crate) fn score_loaded( + loaded: &LoadedDecisions, + request: &contract::DecisionsRequest, +) -> Result { let proto_request = local_request(request); let bytes = serialize(&proto_request); let mut out_buffer = ProtoBuffer::new(); - let started = Instant::now(); - // SAFETY: handle is the live, loaded component; bytes/out_buffer are valid - // for the duration of the call. + // SAFETY: handle is live and loaded; bytes/out_buffer are valid for the + // duration of the call. let proto_rc = unsafe { sys::rac_decision_component_decide_proto( - handle, + loaded.handle, bytes.as_ptr(), bytes.len(), out_buffer.as_mut_ptr(), @@ -848,36 +856,16 @@ fn run_local(options: &GlobalOptions, request: &contract::DecisionsRequest, json let result = match parse_proto_buffer::(out_buffer) { Ok(result) if proto_rc == sys::SUCCESS => result, Ok(result) if !result.answers.is_empty() => { - // A non-success rc with answers in hand is a partial result: some - // question was refused (an over-window prompt, a bad span). The - // remaining answers are real but the request as a whole is not, - // and reporting exit 0 would tell a script the missing ids are - // authoritative "no answer". Fail instead, naming the rc. - out::error_line(&format!( + return Err(format!( "decision failed: {} ({} of {} questions answered)", out::describe_result(proto_rc), result.answers.len(), request.questions.len() )); - // SAFETY: handle is the live component created above. - unsafe { sys::rac_decision_component_destroy(handle) }; - return 1; - } - Ok(_) => { - out::error_line("decision failed: "); - // SAFETY: handle is the live component created above. - unsafe { sys::rac_decision_component_destroy(handle) }; - return 1; - } - Err(error) => { - out::error_line(&format!("decision failed: {error}")); - // SAFETY: handle is the live component created above. - unsafe { sys::rac_decision_component_destroy(handle) }; - return 1; } + Ok(_) => return Err("decision failed: ".to_string()), + Err(error) => return Err(format!("decision failed: {error}")), }; - // SAFETY: handle is the live component created above; not used again. - unsafe { sys::rac_decision_component_destroy(handle) }; // Every requested question must carry an answer, whatever the rc said; // a missing id is the same partial failure the arm above rejects. @@ -885,14 +873,44 @@ fn run_local(options: &GlobalOptions, request: &contract::DecisionsRequest, json .into_iter() .find(|(id, _)| !result.answers.iter().any(|answer| answer.id == *id)) { - out::error_line(&format!( + return Err(format!( "decision failed: no answer for question '{missing}'" )); + } + + Ok(local_response(&result, request)) +} + +fn run_local(options: &GlobalOptions, request: &contract::DecisionsRequest, json: bool) -> i32 { + if bootstrap::bootstrap(options).is_err() { return 1; } + if let Err(error) = crate::commands::model_setup::refresh_registry() { + out::status_line(&format!("warning: registry refresh failed: {error}")); + } + let model = match ensure_model_ready(options, &request.model) { + Ok(model) => model, + Err(exit_code) => return exit_code, + }; + + let loaded = match load_decisions_component(&model) { + Ok(loaded) => loaded, + Err(message) => { + out::error_line(&message); + return 1; + } + }; + + let started = Instant::now(); + let response = match score_loaded(&loaded, request) { + Ok(response) => response, + Err(message) => { + out::error_line(&message); + return 1; + } + }; let latency_ms = started.elapsed().as_millis(); - let response = local_response(&result, request); if json { out::result_line(&crate::io::json::dump(&response.to_json())); } else { diff --git a/src/commands/cmd_serve.rs b/src/commands/cmd_serve.rs index 3b4de27d..b0b1a96b 100644 --- a/src/commands/cmd_serve.rs +++ b/src/commands/cmd_serve.rs @@ -86,6 +86,12 @@ fn run_serve( return 1; } + // Decision checkpoints get their own endpoint: rac_server is LLM-only, + // so a decision model reference serves POST /v1/decisions in-process. + if !reference.is_empty() && is_decision_reference(reference) { + return run_serve_decisions(options, reference, host, port); + } + let model = match ensure_model_ready( options, if reference.is_empty() { @@ -208,6 +214,269 @@ fn run_serve( exit_code } +/// A catalog id or alias naming an on-disk decision model (clef rows). Paths +/// to GGUF files are not covered: they skip the catalog and stay on the +/// rac_server path, which refuses them the way it always has. +#[cfg(wally_has_server)] +fn is_decision_reference(reference: &str) -> bool { + matches!( + crate::catalog::find(reference), + Some(entry) if entry.category == crate::io::proto::v1::ModelCategory::Decision + ) +} + +/// Same 1 MiB cap the CLI enforces on request bodies. +#[cfg(wally_has_server)] +const MAX_SERVE_BODY_BYTES: usize = 1024 * 1024; + +/// Split an HTTP/1 head into method, path (query stripped) and content +/// length. Header names match case-insensitively; anything unreadable is a +/// 400 with the reason. +#[cfg(wally_has_server)] +fn parse_head(head: &[u8]) -> Result<(String, String, usize), String> { + let text = std::str::from_utf8(head).map_err(|_| "unreadable request head".to_string())?; + let mut lines = text.split("\r\n"); + let request_line = lines.next().ok_or_else(|| "empty request".to_string())?; + let mut parts = request_line.split_whitespace(); + let method = parts + .next() + .ok_or_else(|| "bad request line".to_string())? + .to_string(); + let target = parts.next().ok_or_else(|| "bad request line".to_string())?; + let path = target.split('?').next().unwrap_or(target).to_string(); + let mut length: usize = 0; + for line in lines { + if let Some(value) = line + .split_once(':') + .filter(|(name, _)| name.trim().eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value) + { + length = value + .trim() + .parse() + .map_err(|_| "bad content-length".to_string())?; + } + } + Ok((method, path, length)) +} + +#[cfg(wally_has_server)] +fn write_response(stream: &mut std::net::TcpStream, status: u16, reason: &str, body: &str) { + use std::io::Write; + let _ = write!( + stream, + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.flush(); +} + +#[cfg(wally_has_server)] +fn json_error(message: &str) -> String { + let mut json = crate::io::output::JsonWriter::new(); + json.begin_object().field_str("error", message).end_object(); + json.str().to_string() +} + +/// One connection, served synchronously: the decision handle is not shared +/// across threads, so requests queue instead of racing inside the engine. +#[cfg(wally_has_server)] +fn handle_decisions_connection( + stream: &mut std::net::TcpStream, + loaded: &crate::commands::cmd_decisions::LoadedDecisions, +) { + use std::io::Read; + let mut raw: Vec = Vec::new(); + let mut buf = [0u8; 4096]; + let head_end = loop { + match stream.read(&mut buf) { + Ok(0) => return, + Ok(n) => { + raw.extend_from_slice(&buf[..n]); + if raw.len() > 65536 { + write_response( + stream, + 431, + "Request Header Fields Too Large", + &json_error("headers too large"), + ); + return; + } + if let Some(pos) = raw.windows(4).position(|w| w == b"\r\n\r\n") { + break pos + 4; + } + } + Err(_) => return, + } + }; + let (method, path, length) = match parse_head(&raw[..head_end]) { + Ok(parsed) => parsed, + Err(message) => { + write_response(stream, 400, "Bad Request", &json_error(&message)); + return; + } + }; + if path == "/health" && method == "GET" { + let mut json = crate::io::output::JsonWriter::new(); + json.begin_object().field_str("status", "ok").end_object(); + write_response(stream, 200, "OK", &json.str()); + return; + } + if method != "POST" || path != "/v1/decisions" { + write_response(stream, 404, "Not Found", &json_error("not found")); + return; + } + if length > MAX_SERVE_BODY_BYTES { + write_response( + stream, + 413, + "Content Too Large", + &json_error("request body exceeds 1 MiB"), + ); + return; + } + let mut body = raw[head_end..].to_vec(); + while body.len() < length { + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => body.extend_from_slice(&buf[..n]), + Err(_) => break, + } + } + if body.len() < length { + write_response( + stream, + 400, + "Bad Request", + &json_error("truncated request body"), + ); + return; + } + body.truncate(length); + let value: serde_json::Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(_) => { + write_response( + stream, + 400, + "Bad Request", + &json_error("invalid request JSON"), + ); + return; + } + }; + let unknown = crate::commands::cmd_decisions::unknown_request_fields(&value); + if !unknown.is_empty() { + write_response( + stream, + 400, + "Bad Request", + &json_error(&format!( + "request has fields the decisions contract does not define: {}", + unknown.join(", ") + )), + ); + return; + } + let request = match crate::account::decisions_contract::DecisionsRequest::from_json(&value) { + Ok(request) => request, + Err(error) => { + write_response( + stream, + 400, + "Bad Request", + &json_error(&format!( + "request does not match the decisions contract: {error}" + )), + ); + return; + } + }; + if let Err(error) = crate::commands::cmd_decisions::validate(&request) { + write_response(stream, 400, "Bad Request", &json_error(&error)); + return; + } + // The served model answers every request; a body naming another model is + // scored by this server's model, the way `serve` owns its one model. + match crate::commands::cmd_decisions::score_loaded(loaded, &request) { + Ok(response) => { + write_response( + stream, + 200, + "OK", + &crate::io::json::dump(&response.to_json()), + ); + } + Err(message) => { + write_response(stream, 500, "Internal Server Error", &json_error(&message)); + } + } +} + +/// Serve a local decision checkpoint over HTTP: one model loaded once, then +/// POST /v1/decisions per request plus GET /health. Mirrors the CLI's +/// validation so the same body behaves the same in both. +#[cfg(wally_has_server)] +fn run_serve_decisions(options: &GlobalOptions, reference: &str, host: &str, port: u16) -> i32 { + if bootstrap::bootstrap(options).is_err() { + return 1; + } + + let model = match ensure_model_ready(options, reference) { + Ok(model) => model, + Err(code) => return code, + }; + let loaded = match crate::commands::cmd_decisions::load_decisions_component(&model) { + Ok(loaded) => loaded, + Err(message) => { + error_line(&message); + return 1; + } + }; + + let listener = match std::net::TcpListener::bind((host, port)) { + Ok(listener) => listener, + Err(error) => { + error_line(&format!("could not bind {host}:{port}: {error}")); + return 1; + } + }; + if listener.set_nonblocking(true).is_err() { + error_line("could not set the listener non-blocking"); + return 1; + } + + status_line(&format!( + "serving {} (decision model, single model)", + model.model_id + )); + status_line(&format!("Decisions API: http://{host}:{port}/v1/decisions")); + status_line(&format!("health: http://{host}:{port}/health")); + status_line("Ctrl-C to stop"); + + SERVE_STOP.store(false, Ordering::SeqCst); + let _interrupt = crate::util::interrupt::on_interrupt(serve_signal_handler); + loop { + if SERVE_STOP.load(Ordering::SeqCst) { + break; + } + match listener.accept() { + Ok((mut stream, _)) => { + let _ = stream.set_nonblocking(false); + handle_decisions_connection(&mut stream, &loaded); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Err(error) => { + error_line(&format!("accept failed: {error}")); + break; + } + } + } + 0 +} + pub fn register_serve(app: &mut App) { let cmd = app.add_subcommand("serve", "Serve a model over an OpenAI-compatible API"); #[cfg(wally_has_server)] @@ -313,4 +582,30 @@ mod tests { assert_eq!(config.max_concurrent_requests, 4); assert_eq!(config.verbose, sys::RAC_FALSE as sys::rac_bool_t); } + + #[test] + fn head_parses_method_path_and_length() { + let (method, path, length) = + parse_head(b"POST /v1/decisions?x=1 HTTP/1.1\r\nHost: a\r\nContent-Length: 12\r\n\r\n") + .expect("head parses"); + assert_eq!(method, "POST"); + assert_eq!(path, "/v1/decisions"); + assert_eq!(length, 12); + } + + #[test] + fn head_rejects_garbage() { + assert!(parse_head(b"").is_err()); + assert!(parse_head(b"GET\r\n\r\n").is_err()); + assert!(parse_head(b"POST /x HTTP/1.1\r\nContent-Length: lots\r\n\r\n").is_err()); + } + + #[test] + fn decision_ids_route_to_the_decisions_server() { + assert!(is_decision_reference("clef-flash-9b")); + assert!(is_decision_reference("clef-flash-mlx")); + assert!(!is_decision_reference("qwen3-4b-instruct-2507")); + assert!(!is_decision_reference("eve")); + assert!(!is_decision_reference("no-such-model")); + } } From 0d0acda9f893e0cb55191a72c2fd29dee66added Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar Date: Sun, 4 Oct 2026 17:57:23 +0530 Subject: [PATCH 23/38] merge PR 160 features: cloud list, update notice, uninstall keeps models, thinking cache --- README.md | 5 +- docs/EDITORS.md | 8 + docs/MODELS.md | 2 +- install.ps1 | 73 ++- install.sh | 47 +- src/account/console.rs | 15 +- src/anthropic/translate.rs | 260 ++++++++++- src/app.rs | 79 +++- src/commands/cmd_list.rs | 197 +++++++- src/commands/cmd_maintenance.rs | 244 ++++++---- src/commands/cmd_update.rs | 431 +++++++++++++++++- src/commands/mod.rs | 2 +- src/harness/agents.rs | 5 +- src/harness/catalog_models.rs | 12 +- src/harness/opencode.rs | 4 + tests/cpp_case_exceptions.txt | 1 + tests/golden/README.md | 6 + .../expected/help__help__uninstall.stdout | 1 + .../golden/expected/help__help__update.stdout | 3 +- .../help__models__catalog__list.stdout | 5 +- .../golden/expected/help__models__list.stdout | 5 +- tests/golden/expected/help__models__ls.stdout | 5 +- tests/golden/expected/help__uninstall.stdout | 1 + tests/golden/expected/help__update.stdout | 3 +- .../leaf__models__list__bogus_flag.stderr | 5 +- ...eaf__models__list__extra_positional.stderr | 5 +- .../leaf__uninstall__bogus_flag.stderr | 1 + .../leaf__uninstall__extra_positional.stderr | 1 + .../expected/leaf__update__bogus_flag.stderr | 3 +- .../leaf__update__extra_positional.stderr | 3 +- .../expected/local__-q__models__list.stdout | 2 +- .../expected/local__models__list.stdout | 2 +- .../local__models__list__--all.stderr | 1 + .../local__models__list__--all__--json.stdout | 2 +- ...models__list__--home__HOMEelsewhere.stdout | 2 +- .../golden/expected/local__models__ls.stdout | 2 +- ...arse__--json__--bogus__models__list.stderr | 5 +- .../expected/parse__models__list__-u.stderr | 5 +- tests/test_wally_harness.rs | 3 + tests/test_wally_opencode.rs | 25 + tests/test_wally_unit/shim.rs | 24 +- 41 files changed, 1296 insertions(+), 209 deletions(-) diff --git a/README.md b/README.md index 80d6c18e..d4b1a11a 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ wally run qwen3 "Hello" # one answer and exit wally serve qwen3 # OpenAI-compatible API on :8080 (macOS, Linux) ``` -`wally models list --all` shows everything you can pull. Any Hugging Face GGUF +`wally models list --local` shows everything you can pull; `--cloud` shows the +cloud models on your account, and `--all` shows both. Any Hugging Face GGUF works too, by its full path: ```bash @@ -150,7 +151,7 @@ has the full catalog. | Models | | |---|---| -| `wally models list --all` | models on this machine, or the whole catalog | +| `wally models list` | models on this machine; `--local` for the whole catalog, `--cloud` for your cloud models, `--all` for both | | `wally models pull` / `rm` | download or delete a model | | `wally models show` | size, context window, files | | `wally models default` | the model coding tools open with | diff --git a/docs/EDITORS.md b/docs/EDITORS.md index 67bf745e..5181b0c3 100644 --- a/docs/EDITORS.md +++ b/docs/EDITORS.md @@ -40,6 +40,14 @@ which is what lets an agent on the far side run the tools it was given rather than describe them. `opencode`, `hermes` and `openclaw` speak OpenAI already, so they talk to the endpoint directly. +The model's reasoning shows up as a thinking block with an empty signature, +because these models can't sign it. Claude Code accepts that, and wally drops +the block again when Claude Code sends it back. Cache reads show up as +`cache_read_input_tokens`, taken out of `input_tokens` so nothing is counted +twice. Cache writes aren't reported, because the endpoint doesn't report them. +OpenCode, OpenClaw and Prime Agent get the catalog's cache-read price, so their +cost display charges a cached prompt at the cached rate. + Nothing wally writes for a tool outlives the run. The variables go in the child process. DeepSeek Harness is handed a patch in a temp directory, deleted when it exits; its own `$DSH_HOME` is never written to, and the API key never enters diff --git a/docs/MODELS.md b/docs/MODELS.md index 9ed4322c..90a9a0fe 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -1,6 +1,6 @@ # Models -`wally models list --all` is the live list. +`wally models list --local` is the live list. `--cloud` lists the cloud models on your account, and `--all` shows both. This table groups models by publisher; the id you pull depends on the engine you want: diff --git a/install.ps1 b/install.ps1 index cee8ede9..e5e07042 100644 --- a/install.ps1 +++ b/install.ps1 @@ -43,6 +43,49 @@ function Fail([string]$Message) { throw "Error: $Message" } +# Replaces an existing install file by file. Windows will not move a folder +# whose wally.exe or DLLs are in use (`wally update` runs this installer while +# wally itself is running, and another terminal may be running wally too), but +# it will rename each of those files. So every current file is renamed to +# .old, the new files are moved in beside them, and any failure renames +# them all back. A .old file still in use cannot be deleted yet; the next +# update, or the next wally run, removes it. +function Update-InPlace([string]$Source, [string]$Target) { + Get-ChildItem -LiteralPath $Target -Filter '*.old' -Force | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + $Retired = @() + $Placed = @() + try { + foreach ($Item in @(Get-ChildItem -LiteralPath $Target -Force)) { + $Aside = "$($Item.FullName).old" + if (Test-Path -LiteralPath $Aside) { $Aside = "$($Item.FullName).$PID.old" } + Rename-Item -LiteralPath $Item.FullName -NewName (Split-Path $Aside -Leaf) -ErrorAction Stop + $Retired += [pscustomobject]@{ Original = $Item.FullName; Aside = $Aside } + } + foreach ($Item in @(Get-ChildItem -LiteralPath $Source -Force)) { + $Destination = Join-Path $Target $Item.Name + Move-Item -LiteralPath $Item.FullName -Destination $Destination -ErrorAction Stop + $Placed += $Destination + } + } catch { + $Reason = $_.Exception.Message + foreach ($Path in $Placed) { Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue } + foreach ($Entry in $Retired) { + Rename-Item -LiteralPath $Entry.Aside -NewName (Split-Path $Entry.Original -Leaf) -ErrorAction SilentlyContinue + } + Fail "Could not replace the installed files ($Reason); the existing installation was left unchanged." + } + foreach ($Entry in $Retired) { + Remove-Item -LiteralPath $Entry.Aside -Recurse -Force -ErrorAction SilentlyContinue + } +} + +# Check mode: print the latest release version and nothing else, then stop +# without installing. `wally update` and its update-available notice read it. +# An environment variable rather than a flag, because `irm ... | iex` passes +# no arguments. +$CheckOnly = $env:WALLY_INSTALL_CHECK -eq '1' + # Two overrides, for release tests and mirrors rather than everyday use -- # the same shape and names as install.sh's: # WALLY_INSTALL_VERSION install that release instead of the latest @@ -60,12 +103,12 @@ if ($env:WALLY_INSTALL_VERSION) { Fail "WALLY_INSTALL_VERSION must look like 1.2.3, not '$($env:WALLY_INSTALL_VERSION)'" } $Release = $null - Write-Info "Installing v$Version" + if (-not $CheckOnly) { Write-Info "Installing v$Version" } } else { if ($env:WALLY_INSTALL_BASE_URL) { Fail 'WALLY_INSTALL_BASE_URL needs WALLY_INSTALL_VERSION: a mirror has no latest-release lookup' } - Write-Info 'Checking latest Wally release...' + if (-not $CheckOnly) { Write-Info 'Checking latest Wally release...' } try { $Release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest" } catch { @@ -73,7 +116,12 @@ if ($env:WALLY_INSTALL_VERSION) { } $Version = "$($Release.tag_name)" -replace '^v', '' if (-not $Version) { Fail 'Could not determine latest release version. Check your internet connection.' } - Write-Info "Latest version: v$Version" + if (-not $CheckOnly) { Write-Info "Latest version: v$Version" } +} + +if ($CheckOnly) { + Write-Output $Version + return } # PROCESSOR_ARCHITECTURE reports the process, not the machine, so a 32-bit host @@ -187,20 +235,11 @@ try { $InstallParent = Split-Path $InstallDir -Parent New-Item -ItemType Directory -Path $InstallParent -Force | Out-Null - $Backup = "$InstallDir.previous" - Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $InstallDir) { - Move-Item -LiteralPath $InstallDir -Destination $Backup - } - try { + Update-InPlace $Candidate $InstallDir + } else { Move-Item -LiteralPath $Candidate -Destination $InstallDir - } catch { - if (Test-Path -LiteralPath $Backup) { - Move-Item -LiteralPath $Backup -Destination $InstallDir - } - throw } - Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue } finally { Remove-Item -LiteralPath $Temp -Recurse -Force -ErrorAction SilentlyContinue } @@ -217,14 +256,18 @@ $Entries = @() if ($UserPath) { $Entries = @($UserPath -split ';' | Where-Object { $_ }) } if ($Entries -contains $InstallDir) { Write-Ok "$InstallDir is already on your PATH" + $PathChanged = $false } else { [Environment]::SetEnvironmentVariable('Path', (($Entries + $InstallDir) -join ';'), 'User') Write-Ok "Added $InstallDir to your PATH" + $PathChanged = $true } Write-Ok "Wally v$Version installed successfully" Write-Host '' -Write-Warn 'Open a new terminal before running wally. This one was started with the old PATH.' +if ($PathChanged) { + Write-Warn 'Open a new terminal before running wally. This one was started with the old PATH.' +} Write-Host '' Write-Info 'Getting started:' Write-Host ' wally models list --all every model in the catalog' diff --git a/install.sh b/install.sh index 14dd94a0..e1cca9b1 100755 --- a/install.sh +++ b/install.sh @@ -322,16 +322,41 @@ recover_interrupted_install() { return 0 } +# Sets VERSION to the release to install: WALLY_INSTALL_VERSION when given, +# otherwise the latest GitHub release. +resolve_version() { +if [ -n "${WALLY_INSTALL_VERSION:-}" ]; then + VERSION="${WALLY_INSTALL_VERSION#v}" + printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || fail "WALLY_INSTALL_VERSION must look like 1.2.3, not '${WALLY_INSTALL_VERSION}'" +else + [ -z "${WALLY_INSTALL_BASE_URL:-}" ] \ + || fail "WALLY_INSTALL_BASE_URL needs WALLY_INSTALL_VERSION: a mirror has no latest-release lookup" + # shellcheck disable=SC2086 # CURL_RETRY is two options, split on purpose + latest=$(curl -fsSL $CURL_RETRY "https://api.github.com/repos/${REPO}/releases/latest") \ + || fail "Could not determine latest release version. Check your internet connection." + VERSION=$(printf '%s\n' "$latest" \ + | grep '"tag_name"' \ + | sed 's/.*"v\([^"]*\)".*/\1/') +fi +[ -n "$VERSION" ] || fail "Could not determine latest release version. Check your internet connection." +} + main() { # --- arguments -------------------------------------------------------------- # The version the caller already has, passed by `wally update` so the script can # tell it apart from a fresh install and skip the download when nothing is newer. CURRENT_VERSION="" +CHECK_ONLY="" for arg in "$@"; do case "$arg" in nightly|--nightly) fail "nightly/dev installs are no longer published; this installer only supports production releases" ;; --version=*) CURRENT_VERSION="${arg#--version=}" ;; + # Print the latest release version on stdout and nothing else, then + # exit without installing. `wally update --check-for-updates` and the + # update-available notice both read it. + --check) CHECK_ONLY=1 ;; # Debug-only: print the resolved skill targets and exit before any # network work. Exercised by scripts/test/test-install-skill-dirs.sh. --print-skill-dirs) skill_target_dirs; exit 0 ;; @@ -351,27 +376,19 @@ for arg in "$@"; do esac done +if [ -n "$CHECK_ONLY" ]; then + resolve_version + printf '%s\n' "$VERSION" + exit 0 +fi + CHANNEL="production" banner printf ' %sInstalling the %s%s%s build%s\n\n' "$DIM" "$R$B" "$CHANNEL" "$R$DIM" "$R" step "Resolving the latest release" -if [ -n "${WALLY_INSTALL_VERSION:-}" ]; then - VERSION="${WALLY_INSTALL_VERSION#v}" - printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ - || fail "WALLY_INSTALL_VERSION must look like 1.2.3, not '${WALLY_INSTALL_VERSION}'" -else - [ -z "${WALLY_INSTALL_BASE_URL:-}" ] \ - || fail "WALLY_INSTALL_BASE_URL needs WALLY_INSTALL_VERSION: a mirror has no latest-release lookup" - # shellcheck disable=SC2086 # CURL_RETRY is two options, split on purpose - latest=$(curl -fsSL $CURL_RETRY "https://api.github.com/repos/${REPO}/releases/latest") \ - || fail "Could not determine latest release version. Check your internet connection." - VERSION=$(printf '%s\n' "$latest" \ - | grep '"tag_name"' \ - | sed 's/.*"v\([^"]*\)".*/\1/') -fi -[ -n "$VERSION" ] || fail "Could not determine latest release version. Check your internet connection." +resolve_version ok "v${VERSION}" # An update check: the caller told us its version. If nothing newer is out, diff --git a/src/account/console.rs b/src/account/console.rs index 8d63fd8c..d3452856 100644 --- a/src/account/console.rs +++ b/src/account/console.rs @@ -256,6 +256,7 @@ pub struct CatalogPrice { pub id: String, pub input_per_mtok: i64, pub output_per_mtok: i64, + pub cached_input_per_mtok: i64, } #[derive(Debug, Clone, Default, PartialEq)] @@ -1920,6 +1921,17 @@ impl ConsoleClient { &self, console_url: &str, access_token: &str, + ) -> (IdentityResult, Vec, String) { + self.fetch_models_within(console_url, access_token, 0) + } + + /// `fetch_models` bounded to `timeout_ms` in all (0: the transport's + /// defaults), for a caller that has something else to show if it fails. + pub fn fetch_models_within( + &self, + console_url: &str, + access_token: &str, + timeout_ms: i32, ) -> (IdentityResult, Vec, String) { if !super::session_token_is_safe(access_token) { return ( @@ -1937,7 +1949,7 @@ impl ConsoleClient { url: format!("{origin}/v1/models"), body: String::new(), bearer_token: access_token.to_string(), - timeout_ms: 0, + timeout_ms, }; let response = match self.send(request) { Ok(response) => response, @@ -2045,6 +2057,7 @@ impl ConsoleClient { id: model.id, input_per_mtok: model.input_per_mtok, output_per_mtok: model.output_per_mtok, + cached_input_per_mtok: model.cached_input_per_mtok, }) .collect(); (IdentityResult::Ok, prices, String::new()) diff --git a/src/anthropic/translate.rs b/src/anthropic/translate.rs index 1e7c3020..6829d36b 100644 --- a/src/anthropic/translate.rs +++ b/src/anthropic/translate.rs @@ -22,6 +22,8 @@ pub struct StreamState { pub opened: bool, pub block_open: bool, pub text_index: i32, + pub thinking_open: bool, + pub thinking_index: i32, pub next_index: i32, pub failed: bool, pub closed: bool, @@ -30,6 +32,9 @@ pub struct StreamState { pub stop_reason: String, pub input_tokens: i32, pub output_tokens: i32, + /// `prompt_tokens_details.cached_tokens` when upstream reported it; None + /// keeps the usage block exactly as it was before cache reporting. + pub cached_tokens: Option, pub input_estimate: i32, pub output_chars: i32, pub tool_calls: BTreeMap, @@ -44,6 +49,8 @@ impl Default for StreamState { opened: false, block_open: false, text_index: -1, + thinking_open: false, + thinking_index: -1, next_index: 0, failed: false, closed: false, @@ -52,6 +59,7 @@ impl Default for StreamState { stop_reason: String::new(), input_tokens: 0, output_tokens: 0, + cached_tokens: None, input_estimate: 0, output_chars: 0, tool_calls: BTreeMap::new(), @@ -277,6 +285,62 @@ fn estimate_tokens_from_chars(chars: usize) -> i32 { } } +/// `usage.prompt_tokens_details.cached_tokens`, when upstream sent it. +fn cached_tokens(usage: &Value) -> Option { + usage + .get("prompt_tokens_details") + .and_then(|details| details.get("cached_tokens")) + .and_then(Value::as_i64) + .map(|v| v as i32) +} + +/// Anthropic usage from OpenAI counts. Anthropic counts cache reads apart +/// from `input_tokens` while OpenAI's `prompt_tokens` includes them, so the +/// cached part moves to `cache_read_input_tokens` and the total is unchanged. +fn anthropic_usage(prompt: i32, output: i32, cached: Option) -> Value { + match cached { + Some(cached) => { + let cached = cached.clamp(0, prompt.max(0)); + json!({ + "input_tokens": prompt - cached, + "output_tokens": output, + "cache_read_input_tokens": cached, + }) + } + None => json!({"input_tokens": prompt, "output_tokens": output}), + } +} + +fn read_stream_usage(usage: &Value, state: &mut StreamState) { + state.input_tokens = count(usage, "prompt_tokens", state.input_tokens); + state.output_tokens = count(usage, "completion_tokens", state.output_tokens); + if let Some(cached) = cached_tokens(usage) { + state.cached_tokens = Some(cached); + } +} + +fn close_text_block(state: &mut StreamState) -> String { + if !state.block_open { + return String::new(); + } + state.block_open = false; + event( + "content_block_stop", + &json!({"type": "content_block_stop", "index": state.text_index}), + ) +} + +fn close_thinking_block(state: &mut StreamState) -> String { + if !state.thinking_open { + return String::new(); + } + state.thinking_open = false; + event( + "content_block_stop", + &json!({"type": "content_block_stop", "index": state.thinking_index}), + ) +} + // ---- public API ------------------------------------------------------- /// One Anthropic `/v1/messages` request body translated to OpenAI @@ -457,9 +521,17 @@ pub fn response_to_anthropic(openai: &Value, model: &str) -> Value { .to_string(); let mut content = Vec::new(); + // The model's reasoning, shown the way Anthropic shows its own. The + // signature is empty: no upstream here signs its reasoning, and the + // block is dropped again when Claude Code sends the history back. + let reasoning = field(&message, "reasoning_content"); + if !reasoning.is_empty() { + content.push(json!({"type": "thinking", "thinking": reasoning, "signature": ""})); + } if !text.is_empty() { content.push(json!({"type": "text", "text": text})); } + let blocks_before_tools = content.len(); if let Some(calls) = message.get("tool_calls").and_then(Value::as_array) { for call in calls { if !call.is_object() { @@ -482,7 +554,7 @@ pub fn response_to_anthropic(openai: &Value, model: &str) -> Value { })); } } - let tool_called = content.len() > if text.is_empty() { 0 } else { 1 }; + let tool_called = content.len() > blocks_before_tools; if content.is_empty() { content.push(json!({"type": "text", "text": ""})); } @@ -503,10 +575,11 @@ pub fn response_to_anthropic(openai: &Value, model: &str) -> Value { "content": content, "stop_reason": if stop.is_empty() { Value::Null } else { json!(stop) }, "stop_sequence": Value::Null, - "usage": { - "input_tokens": count(&usage, "prompt_tokens", 0), - "output_tokens": count(&usage, "completion_tokens", 0), - }, + "usage": anthropic_usage( + count(&usage, "prompt_tokens", 0), + count(&usage, "completion_tokens", 0), + cached_tokens(&usage), + ), }) } @@ -609,8 +682,7 @@ pub fn stream_chunk_to_anthropic(chunk: &Value, state: &mut StreamState) -> Stri ); } if let Some(usage) = chunk.get("usage").filter(|u| u.is_object()) { - state.input_tokens = count(usage, "prompt_tokens", state.input_tokens); - state.output_tokens = count(usage, "completion_tokens", state.output_tokens); + read_stream_usage(usage, state); } return out; } @@ -629,8 +701,7 @@ pub fn stream_chunk_to_anthropic(chunk: &Value, state: &mut StreamState) -> Stri } if let Some(usage) = chunk.get("usage").filter(|u| u.is_object()) { - state.input_tokens = count(usage, "prompt_tokens", state.input_tokens); - state.output_tokens = count(usage, "completion_tokens", state.output_tokens); + read_stream_usage(usage, state); } if let Some(d) = choice.get("delta") { @@ -732,10 +803,38 @@ pub fn stream_chunk_to_anthropic(chunk: &Value, state: &mut StreamState) -> Stri } } - // Reasoning tokens count toward the output estimate but never surface as - // their own block; Anthropic has no equivalent block type here. - if let Some(reasoning) = delta.get("reasoning_content").and_then(Value::as_str) { + // Reasoning streams as a thinking block, the way Anthropic streams its + // own. Blocks never overlap: a thinking delta closes an open text block, + // and text closes an open thinking block, each opening at the next index. + // The signature is empty; see response_to_anthropic. + if let Some(reasoning) = delta + .get("reasoning_content") + .and_then(Value::as_str) + .filter(|r| !r.is_empty()) + { state.output_chars += reasoning.len() as i32; + out += &close_text_block(state); + if !state.thinking_open { + state.thinking_open = true; + state.thinking_index = state.next_index; + state.next_index += 1; + out += &event( + "content_block_start", + &json!({ + "type": "content_block_start", + "index": state.thinking_index, + "content_block": {"type": "thinking", "thinking": "", "signature": ""}, + }), + ); + } + out += &event( + "content_block_delta", + &json!({ + "type": "content_block_delta", + "index": state.thinking_index, + "delta": {"type": "thinking_delta", "thinking": reasoning}, + }), + ); } let text = delta @@ -748,6 +847,7 @@ pub fn stream_chunk_to_anthropic(chunk: &Value, state: &mut StreamState) -> Stri } state.output_chars += text.len() as i32; + out += &close_thinking_block(state); if !state.block_open { state.block_open = true; state.text_index = state.next_index; @@ -810,14 +910,8 @@ pub fn stream_close_to_anthropic(state: &mut StreamState) -> String { } state.closed = true; - let mut out = String::new(); - if state.block_open { - state.block_open = false; - out += &event( - "content_block_stop", - &json!({"type": "content_block_stop", "index": state.text_index}), - ); - } + let mut out = close_thinking_block(state); + out += &close_text_block(state); let mut index = state.next_index; let mut emitted_tool_block = false; @@ -867,7 +961,7 @@ pub fn stream_close_to_anthropic(state: &mut StreamState) -> String { &json!({ "type": "message_delta", "delta": {"stop_reason": stop, "stop_sequence": Value::Null}, - "usage": {"input_tokens": input_final, "output_tokens": output_final}, + "usage": anthropic_usage(input_final, output_final, state.cached_tokens), }), ); out += &event("message_stop", &json!({"type": "message_stop"})); @@ -1108,4 +1202,128 @@ mod tests { assert!(out.contains("\"model\":\"claude-3-5-sonnet-20241022\"")); assert_eq!(state.model, "claude-3-5-sonnet-20241022"); } + + fn chunk(delta: Value, finish: Value) -> Value { + json!({"id": "c1", "choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}) + } + + fn streamed(chunks: &[Value]) -> String { + let mut state = StreamState::new(); + state.model = "m".to_string(); + let mut out = String::new(); + for c in chunks { + out += &stream_chunk_to_anthropic(c, &mut state); + } + out + &stream_close_to_anthropic(&mut state) + } + + /// The SSE stream as (event type, index, block or delta type) rows. + fn shape(sse: &str) -> Vec<(String, i64, String)> { + sse.lines() + .filter_map(|line| line.strip_prefix("data: ")) + .map(|data| serde_json::from_str::(data).unwrap()) + .map(|e| { + let kind = e["content_block"]["type"] + .as_str() + .or(e["delta"]["type"].as_str()) + .unwrap_or("") + .to_string(); + ( + e["type"].as_str().unwrap().to_string(), + e["index"].as_i64().unwrap_or(-1), + kind, + ) + }) + .collect() + } + + fn row(event: &str, index: i64, kind: &str) -> (String, i64, String) { + (event.to_string(), index, kind.to_string()) + } + + #[test] + fn reasoning_then_text_streams_a_thinking_block_then_a_text_block() { + let out = streamed(&[ + chunk(json!({"reasoning_content": "let me "}), Value::Null), + chunk(json!({"reasoning_content": "check"}), Value::Null), + chunk(json!({"content": "51"}), json!("stop")), + ]); + let rows = shape(&out); + assert_eq!( + rows[1..7], + [ + row("content_block_start", 0, "thinking"), + row("content_block_delta", 0, "thinking_delta"), + row("content_block_delta", 0, "thinking_delta"), + row("content_block_stop", 0, ""), + row("content_block_start", 1, "text"), + row("content_block_delta", 1, "text_delta"), + ] + ); + assert_eq!(rows[7], row("content_block_stop", 1, "")); + assert!(out.contains(r#""thinking":"let me ""#)); + } + + #[test] + fn reasoning_only_closes_the_thinking_block_and_opens_no_text_block() { + let out = streamed(&[ + chunk(json!({"reasoning_content": "hmm"}), Value::Null), + chunk(json!({}), json!("length")), + ]); + let rows = shape(&out); + assert!(rows.contains(&row("content_block_stop", 0, ""))); + assert!(!rows.iter().any(|r| r.2 == "text")); + assert!(out.contains(r#""stop_reason":"max_tokens""#)); + } + + #[test] + fn non_streaming_reasoning_becomes_a_thinking_block_before_the_text() { + let reply = response_to_anthropic( + &json!({"choices": [{"message": {"reasoning_content": "think", "content": "51"}, "finish_reason": "stop"}]}), + "m", + ); + assert_eq!( + reply["content"], + json!([ + {"type": "thinking", "thinking": "think", "signature": ""}, + {"type": "text", "text": "51"}, + ]) + ); + assert_eq!(reply["stop_reason"], json!("end_turn")); + } + + #[test] + fn cached_tokens_move_from_input_to_cache_read() { + let out = streamed(&[ + chunk(json!({"content": "hi"}), json!("stop")), + json!({"id": "c1", "choices": [], "usage": {"prompt_tokens": 3779, "completion_tokens": 9, "prompt_tokens_details": {"cached_tokens": 3776}}}), + ]); + assert!(out.contains( + r#""usage":{"cache_read_input_tokens":3776,"input_tokens":3,"output_tokens":9}"# + )); + } + + #[test] + fn absent_cached_tokens_leave_usage_as_it_was() { + let reply = response_to_anthropic( + &json!({"choices": [{"message": {"content": "hi"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 10, "completion_tokens": 2}}), + "m", + ); + assert_eq!( + reply["usage"], + json!({"input_tokens": 10, "output_tokens": 2}) + ); + } + + #[test] + fn zero_cached_tokens_report_a_zero_cache_read() { + let reply = response_to_anthropic( + &json!({"choices": [{"message": {"content": "hi"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 10, "completion_tokens": 2, "prompt_tokens_details": {"cached_tokens": 0}}}), + "m", + ); + assert_eq!( + reply["usage"], + json!({"input_tokens": 10, "output_tokens": 2, "cache_read_input_tokens": 0}) + ); + } } diff --git a/src/app.rs b/src/app.rs index 40de7e4d..140f8a39 100644 --- a/src/app.rs +++ b/src/app.rs @@ -175,6 +175,36 @@ fn is_passthrough_command(token: &str) -> bool { NAMES.contains(&token) } +/// The commands that end with the update-available notice: every command on +/// the help page except `update` (it is the update) and `uninstall` (there is +/// nothing left to update). Help pages show it too, from run(). +fn shows_update_notice(path: &[String]) -> bool { + const NAMES: &[&str] = &["run", "serve", "models", "account", "about"]; + path.first() + .is_some_and(|name| NAMES.contains(&name.as_str()) || is_passthrough_command(name)) +} + +/// The update-available notice, unless `argv` asked for JSON or quiet output. +fn update_notice(argv: &[String], no_color_requested: bool) { + let flags = root_flags_before_passthrough(argv); + crate::commands::show_update_notice( + flags.contains(&"--json"), + flags.iter().any(|f| *f == "-q" || *f == "--quiet"), + no_color_requested, + ); +} + +/// wally's tokens on a parsed command line: everything before the `--` that +/// hands the rest to a wrapped tool, so the tool's own `--json` or `-q` is not +/// read as wally's. `argv` includes the program name at index 0. +fn root_flags_before_passthrough(argv: &[String]) -> Vec<&str> { + argv.iter() + .skip(1) + .take_while(|token| *token != "--") + .map(String::as_str) + .collect() +} + /// wally's own flags on those subcommands. `-m`/`--model` take a following /// value; the rest are booleans. The `=` forms carry their value inline. fn consumes_following_value(token: &str) -> bool { @@ -289,6 +319,7 @@ fn restore_stale_desktop_gateway(argv: &[String]) { /// Run wally with `args` (program name at index 0). Returns the exit code. pub fn run(args: &[String]) -> i32 { restore_stale_desktop_gateway(args); + crate::commands::sweep_replaced_files(); // `-u`/`-U` top-level shortcuts, guarded to the one shape that can't be // confused with a subcommand's own arguments: the entire command line is @@ -297,7 +328,7 @@ pub fn run(args: &[String]) -> i32 { if args.len() == 2 { let only_arg = args[1].as_str(); if only_arg == "-u" || only_arg == "--update" { - let code = crate::commands::run_update(false); + let code = crate::commands::run_update(); bootstrap::shutdown(); return code; } @@ -305,7 +336,7 @@ pub fn run(args: &[String]) -> i32 { // This shortcut is exactly one bare arg (args.len() == 2 above), // so it can never carry a `--home` override; an empty string // preserves the shortcut's existing default-home behavior. - let code = crate::commands::run_uninstall(false, ""); + let code = crate::commands::run_uninstall(false, false, ""); bootstrap::shutdown(); return code; } @@ -356,12 +387,14 @@ pub fn run(args: &[String]) -> i32 { { let text = app.render_help(&[args[2].clone()], color_enabled(no_color_requested)); output::result_line(text.trim_end_matches('\n')); + update_notice(args, no_color_requested); bootstrap::shutdown(); return 0; } // No such command (or none given): fall back to the top-level help. let text = app.render_help(&[], color_enabled(no_color_requested)); output::result_line(text.trim_end_matches('\n')); + update_notice(args, no_color_requested); bootstrap::shutdown(); return 0; } @@ -382,14 +415,19 @@ pub fn run(args: &[String]) -> i32 { // reproduces the doubled newline exactly. let text = app.render_help(&[], color_enabled(no_color_requested)); output::status_line(&text); + update_notice(&forwarded, no_color_requested); 0 } else { + if shows_update_notice(&path) { + update_notice(&forwarded, no_color_requested); + } code } } Outcome::Help { path } => { let text = app.render_help(&path, color_enabled(no_color_requested)); output::result_line(text.trim_end_matches('\n')); + update_notice(&forwarded, no_color_requested); 0 } Outcome::Version { text } => { @@ -429,3 +467,40 @@ fn print_parse_error_help(app: &App, message: &str, path: &[String], no_color_re let text = app.render_help(path, color_enabled(no_color_requested)); eprint!("{}", text); } + +#[cfg(test)] +mod tests { + use super::*; + + fn argv(tokens: &[&str]) -> Vec { + tokens.iter().map(|t| t.to_string()).collect() + } + + #[test] + fn update_notice_shows_on_help_page_commands_only() { + for name in [ + "run", + "serve", + "models", + "account", + "about", + "opencode", + "claude-code", + ] { + assert!(shows_update_notice(&argv(&[name])), "{name}"); + } + for name in ["update", "uninstall", "bench", "llm"] { + assert!(!shows_update_notice(&argv(&[name])), "{name}"); + } + assert!(!shows_update_notice(&[])); + } + + #[test] + fn root_flags_stop_at_the_passthrough_separator() { + let line = argv(&["wally", "--json", "opencode", "--", "--quiet"]); + assert_eq!( + root_flags_before_passthrough(&line), + vec!["--json", "opencode"] + ); + } +} diff --git a/src/commands/cmd_list.rs b/src/commands/cmd_list.rs index 7f76cfc4..382dff6f 100644 --- a/src/commands/cmd_list.rs +++ b/src/commands/cmd_list.rs @@ -1,5 +1,7 @@ //! `wally models list` (alias `wally models ls`) — downloaded models by -//! default, the whole catalog with --all (port of src/commands/cmd_list.cpp). +//! default; the whole local catalog with --local; the signed-in account's +//! cloud models with --cloud; both with --all (port of +//! src/commands/cmd_list.cpp). //! //! The registry is refreshed with rescan_local so on-disk artifacts pulled by //! previous runs (or by the test rig / playground tooling) are linked before @@ -7,6 +9,7 @@ use std::collections::HashMap; +use crate::account::{ConsoleClient, ConsoleSession}; use crate::bootstrap::{bootstrap, GlobalOptions}; use crate::cli::App; use crate::commands::model_labels; @@ -212,7 +215,78 @@ fn settle_row_id_and_size(row: &mut GroupedRow) { } } -fn run_list(options: &GlobalOptions, show_all: bool) -> i32 { +// A list command must not sit on a dead network; the cached list is the +// fallback. +const CLOUD_LOOKUP_TIMEOUT_MS: i32 = 3_000; + +/// Where the cloud rows came from. +enum CloudModels { + Live(Vec), + /// The console could not be reached; these are the ids the last + /// successful lookup saved (`account::cached_model_ids`). + Cached(Vec), +} + +/// The hosted models the signed-in account can use, or why there are none to +/// show. Ordered as the console lists them. +fn cloud_models() -> Result { + let mut session = ConsoleSession::open(ConsoleClient::default())?; + match session + .call(|client, url, token| client.fetch_models_within(url, token, CLOUD_LOOKUP_TIMEOUT_MS)) + { + Ok(models) => Ok(CloudModels::Live( + models + .into_iter() + .map(|model| model.id) + .filter(|id| !id.is_empty()) + .collect(), + )), + Err(reason) => { + let cached = crate::account::cached_model_ids(); + if cached.is_empty() { + Err(reason) + } else { + Ok(CloudModels::Cached(cached)) + } + } + } +} + +/// The `[cloud]` tag, blue where stdout takes color. +fn cloud_tag(no_color: bool) -> String { + let color = !no_color + && crate::util::term::stdout_is_tty() + && crate::util::getenv("NO_COLOR").is_none(); + if color { + "\x1b[34m[cloud]\x1b[0m".to_string() + } else { + "[cloud]".to_string() + } +} + +/// Which rows `models list` shows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Scope { + Downloaded, + Local, + Cloud, + All, +} + +impl Scope { + fn local_rows(self) -> bool { + self != Scope::Cloud + } + fn whole_catalog(self) -> bool { + matches!(self, Scope::Local | Scope::All) + } + fn cloud_rows(self) -> bool { + matches!(self, Scope::Cloud | Scope::All) + } +} + +fn run_list(options: &GlobalOptions, scope: Scope) -> i32 { + let show_all = scope.whole_catalog(); let Ok(_env) = bootstrap(options) else { return 1; }; @@ -262,7 +336,37 @@ fn run_list(options: &GlobalOptions, show_all: bool) -> i32 { } } - let (order, groups) = group_models(&all_models.models, &downloaded_ids, show_all); + let (order, groups) = if scope.local_rows() { + group_models(&all_models.models, &downloaded_ids, show_all) + } else { + (Vec::new(), HashMap::new()) + }; + + // Under --all a failure to reach the cloud never fails the local list; it + // is one line on stderr saying why they are missing. Under --cloud the + // cloud is all that was asked for, so the same failure is the error. + let mut cloud_ids: Vec = Vec::new(); + if scope.cloud_rows() { + match cloud_models() { + Ok(CloudModels::Live(ids)) => cloud_ids = ids, + Ok(CloudModels::Cached(ids)) => { + if !options.json { + out::status_line( + "could not reach Wally Cloud; cloud models are from the last time wally did", + ); + } + cloud_ids = ids; + } + Err(reason) if scope == Scope::Cloud => { + out::error_line(&format!("could not list cloud models: {reason}")); + return 1; + } + Err(reason) if !options.json => { + out::status_line(&format!("cloud models not shown: {reason}")); + } + Err(_) => {} + } + } if options.json { let mut json = out::JsonWriter::new(); @@ -281,6 +385,20 @@ fn run_list(options: &GlobalOptions, show_all: bool) -> i32 { // the group is downloaded (mirrors the pre-merge shape, which // callers already treat "" as "not downloaded"). .field_str("local_path", &row.local_path) + .field_bool("cloud", false) + .end_object(); + } + for id in &cloud_ids { + json.begin_array_object() + .field_str("id", id) + .field_str("name", id) + .field_str("modality", "llm") + .field_str("backend", "cloud") + .field_i64("size_bytes", 0) + .field_bool("downloaded", false) + .field_bool("harness_compatible", true) + .field_str("local_path", "") + .field_bool("cloud", true) .end_object(); } json.end_array().end_object(); @@ -288,7 +406,9 @@ fn run_list(options: &GlobalOptions, show_all: bool) -> i32 { return 0; } - print_pull_examples(); + if scope.local_rows() { + print_pull_examples(); + } let mut rows: Vec> = Vec::new(); for key in &order { @@ -312,11 +432,25 @@ fn run_list(options: &GlobalOptions, show_all: bool) -> i32 { ]); } + let tag = cloud_tag(options.no_color); + for id in &cloud_ids { + rows.push(vec![ + id.clone(), + "llm".to_string(), + "cloud".to_string(), + "-".to_string(), + "-".to_string(), + tag.clone(), + ]); + } + if rows.is_empty() { - out::result_line(if show_all { + out::result_line(if scope == Scope::Cloud { + "no cloud models on this account" + } else if show_all { "no models registered" } else { - "no models downloaded — try `wally models list --all` then `wally models pull `" + "no models downloaded — try `wally models list --local` then `wally models pull `" }); return 0; } @@ -327,12 +461,33 @@ fn run_list(options: &GlobalOptions, show_all: bool) -> i32 { 0 } +/// The scope the flags ask for, or why they contradict each other. +fn list_scope(all: bool, local: bool, cloud: bool) -> Result { + match (all, local, cloud) { + (false, false, false) => Ok(Scope::Downloaded), + (true, false, false) => Ok(Scope::All), + (false, true, false) => Ok(Scope::Local), + (false, false, true) => Ok(Scope::Cloud), + _ => Err("pick one of --all, --local or --cloud"), + } +} + pub fn configure_models_list(cmd: &mut App) { - cmd.add_flag("--all,-a", "Include catalog models not yet downloaded"); - cmd.callback(|p, g| { - let show_all = p.flag("--all"); - run_list(g, show_all) - }); + cmd.add_flag( + "--all,-a", + "Every local catalog model, plus your account's cloud models", + ); + cmd.add_flag("--local", "Every local catalog model, without cloud models"); + cmd.add_flag("--cloud", "Only your account's cloud models"); + cmd.callback( + |p, g| match list_scope(p.flag("--all"), p.flag("--local"), p.flag("--cloud")) { + Ok(scope) => run_list(g, scope), + Err(message) => { + out::error_line(message); + 2 + } + }, + ); } #[cfg(test)] @@ -470,4 +625,24 @@ mod tests { let (_, groups) = group_models(&models, &downloaded, true); assert_eq!(groups["qwen3-4b-instruct-2507"].size_bytes, 0); } + + #[test] + fn list_scope_maps_one_flag_to_one_scope() { + assert_eq!(list_scope(false, false, false), Ok(Scope::Downloaded)); + assert_eq!(list_scope(true, false, false), Ok(Scope::All)); + assert_eq!(list_scope(false, true, false), Ok(Scope::Local)); + assert_eq!(list_scope(false, false, true), Ok(Scope::Cloud)); + } + + #[test] + fn list_scope_refuses_two_flags_at_once() { + assert!(list_scope(true, true, false).is_err()); + assert!(list_scope(false, true, true).is_err()); + assert!(list_scope(true, false, true).is_err()); + } + + #[test] + fn cloud_tag_is_plain_when_color_is_off() { + assert_eq!(cloud_tag(true), "[cloud]"); + } } diff --git a/src/commands/cmd_maintenance.rs b/src/commands/cmd_maintenance.rs index 41ec06e1..078077e6 100644 --- a/src/commands/cmd_maintenance.rs +++ b/src/commands/cmd_maintenance.rs @@ -324,22 +324,14 @@ fn unix_exe_targets(exe: &str) -> Vec { /// `home_override` is the global `--home` flag, so uninstall removes the same /// model store the other model commands are pointed at, not just the default /// one. -pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { +/// +/// Downloaded models are kept unless the person says otherwise: the store is +/// shared with other RunAnywhere apps, and it is the one thing here that is +/// slow to get back. Interactively they are asked separately; under `--yes` +/// only `--delete-models` removes them. +pub fn run_uninstall(yes: bool, delete_models: bool, home_override: &str) -> i32 { let mut targets: Vec = Vec::new(); - // Windows cannot delete the program file backing its own running - // process (unlike a Unix inode, which stays live under an unlinked - // name until the process exits) -- see the note below, where this is - // reported instead of attempted. - #[cfg(windows)] - let mut manual_removal: Option = None; - let models = models_directory(home_override); - if !models.is_empty() { - targets.push(Target { - label: "models", - path: PathBuf::from(models), - }); - } let config = credentials::profile_directory(); if !config.is_empty() { targets.push(Target { @@ -348,25 +340,20 @@ pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { }); } + // Windows cannot delete the program files backing its own running + // process, so the install folder is handed to a helper that removes it + // once this process has exited (see remove_after_exit). + #[cfg(windows)] + let mut windows_install: Option = None; let exe = self_executable(); if !exe.is_empty() { - // A running exe cannot be deleted on Windows. One that install.ps1 - // put down is named for the person to remove; any other (a source - // build) is left alone, as the C++ build always did there. #[cfg(windows)] { - if let Some(dir) = windows_install_directory(&exe) { - manual_removal = Some(dir); - } + windows_install = windows_install_directory(&exe); } #[cfg(not(windows))] { targets.extend(unix_exe_targets(&exe)); - // Only install.sh's own tree is ever deleted (unix_exe_targets - // above); a Homebrew install or an unverified (e.g. source-build) - // location is reported instead, the same way the Windows branch - // only reports manual_removal rather than deleting an unverified - // binary. if install_sh_layout(&exe).is_none() { if crate::commands::cmd_update::is_homebrew_managed(&exe) { out::status_line( @@ -383,54 +370,58 @@ pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { } let present: Vec = targets.into_iter().filter(|t| t.path.exists()).collect(); + let models = models_directory(home_override); + let models = (!models.is_empty()) + .then(|| PathBuf::from(models)) + .filter(|path| path.exists()); #[cfg(windows)] - let has_manual_removal = manual_removal.is_some(); + let has_install = windows_install.is_some(); #[cfg(not(windows))] - let has_manual_removal = false; + let has_install = false; - if present.is_empty() && !has_manual_removal { + if present.is_empty() && !has_install && models.is_none() { out::status_line("nothing to uninstall; wally is already gone."); return 0; } - if !present.is_empty() { + // Models are listed with everything else only when --delete-models + // already chose them; otherwise they get a question of their own. + let models_listed = models.as_ref().filter(|_| delete_models); + let models_asked = models.as_ref().filter(|_| !delete_models); + let has_listed = !present.is_empty() || has_install || models_listed.is_some(); + if has_listed { out::status_line("wally uninstall will delete:"); for target in &present { - let size = human_size(&target.path); - let size_suffix = if size.is_empty() { - String::new() - } else { - format!(" ({size})") - }; out::status_line(&format!( - " {} {}{size_suffix}", + " {} {}{}", target.label, - target.path.display() + target.path.display(), + size_suffix(&target.path) + )); + } + #[cfg(windows)] + if let Some(dir) = &windows_install { + out::status_line(&format!(" install {}{}", dir.display(), size_suffix(dir))); + } + if let Some(models) = models_listed { + out::status_line(&format!( + " models {}{}", + display_path(models), + size_suffix(models) )); } - } - #[cfg(windows)] - if let Some(dir) = &manual_removal { - // Windows will not let a running process delete (or even rename) its - // own program file, so there is no safe way to have this process - // finish that part of the job itself. Spawning a detached helper - // that waits on our pid and deletes behind us is the other option - // the C++ never had either, but it trades one guaranteed, honest - // message for a background process that can be killed, blocked by - // antivirus, or race a relaunch of wally -- worse failure modes than - // telling the person the one folder left to remove by hand. - out::status_line(&format!( - "wally cannot delete its own running program on Windows; close this window, \ - then delete {} yourself to finish uninstalling.", - dir.display() - )); } out::status_line("your coding tools (claude-code, opencode, ...) are left untouched."); - if !present.is_empty() && !yes { - // Never delete without a real confirmation. A non-interactive shell - // (piped or redirected stdin) cannot answer, so it must pass --yes on - // purpose rather than have the prompt silently skipped. + let mut remove_models = models_listed.is_some(); + if yes { + if let Some(models) = models_asked { + out::status_line(&format!( + "kept your downloaded models in {} (pass --delete-models to delete them)", + display_path(models) + )); + } + } else { if !term::stdin_is_tty() { out::error_line( "uninstall needs a terminal to confirm; re-run with --yes to delete \ @@ -438,10 +429,18 @@ pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { ); return 1; } - if !confirm("delete all of the above?") { + if has_listed && !confirm("delete all of the above?") { out::status_line("aborted; nothing was deleted."); return 1; } + if let Some(models) = models_asked { + out::status_line(&format!( + "\nyour downloaded models: {}{}", + display_path(models), + size_suffix(models) + )); + remove_models = confirm("delete them too?"); + } } // Everything but the running binary (or the tree containing it) first; @@ -451,37 +450,43 @@ pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { // tree out from under it. let mut failures = 0; let mut deferred: Option = None; - for target in &present { - if target.label == "install" { - deferred = Some(target.path.clone()); - continue; + let mut removals: Vec<&Path> = present.iter().map(|t| t.path.as_path()).collect(); + if remove_models { + if let Some(models) = &models { + removals.push(models.as_path()); } - if let Err(e) = remove_all(&target.path) { - out::error_line(&format!( - "could not delete {}: {}", - target.path.display(), - os_error_message(&e) - )); - failures += 1; + } + for path in removals { + if present + .iter() + .any(|t| t.label == "install" && t.path == path) + { + deferred = Some(path.to_path_buf()); + continue; } + failures += report_removal(path); } if let Some(deferred) = deferred { - if let Err(e) = remove_all(&deferred) { + failures += report_removal(&deferred); + } + #[cfg(windows)] + if let Some(dir) = &windows_install { + if remove_after_exit(dir) { + out::status_line(&format!( + "{} is removed as soon as this command exits.", + dir.display() + )); + } else { out::error_line(&format!( - "could not delete {}: {}", - deferred.display(), - os_error_message(&e) + "could not schedule removal of {}; delete it yourself after closing wally.", + dir.display() )); failures += 1; } } + // Reported above already; counted here so a script checking the exit // code sees an incomplete uninstall rather than a clean 0. - #[cfg(windows)] - if has_manual_removal { - failures += 1; - } - if failures != 0 { return 1; } @@ -489,6 +494,77 @@ pub fn run_uninstall(yes: bool, home_override: &str) -> i32 { 0 } +/// `path` with one separator style: the models directory is built with `/` +/// while the Windows paths around it use `\`. +fn display_path(path: &Path) -> String { + let text = path.display().to_string(); + if cfg!(windows) { + text.replace('/', "\\") + } else { + text + } +} + +fn size_suffix(path: &Path) -> String { + let size = human_size(path); + if size.is_empty() { + String::new() + } else { + format!(" ({size})") + } +} + +/// Deletes `path`, printing why when it cannot. Returns the failure count. +fn report_removal(path: &Path) -> i32 { + match remove_all(path) { + Ok(()) => 0, + Err(e) => { + out::error_line(&format!( + "could not delete {}: {}", + path.display(), + os_error_message(&e) + )); + 1 + } + } +} + +/// Starts a hidden PowerShell that waits for this process to exit, deletes +/// `dir`, and drops it from the user's PATH (install.ps1 added it there). The +/// helper is a separate process because Windows will not delete the files of +/// a running program. The paths reach PowerShell as environment variables, +/// never spliced into the command. +#[cfg(windows)] +fn remove_after_exit(dir: &Path) -> bool { + use std::os::windows::process::CommandExt; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let script = r#"Wait-Process -Id $env:WALLY_UNINSTALL_PID -ErrorAction SilentlyContinue +for ($i = 0; $i -lt 20 -and (Test-Path -LiteralPath $env:WALLY_UNINSTALL_DIR); $i++) { + Remove-Item -LiteralPath $env:WALLY_UNINSTALL_DIR -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $env:WALLY_UNINSTALL_DIR) { Start-Sleep -Milliseconds 250 } +} +$entries = @([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | Where-Object { $_ -and $_ -ne $env:WALLY_UNINSTALL_DIR }) +[Environment]::SetEnvironmentVariable('Path', ($entries -join ';'), 'User')"#; + std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + ]) + .arg(script) + .env("WALLY_UNINSTALL_PID", std::process::id().to_string()) + .env("WALLY_UNINSTALL_DIR", dir) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW) + .spawn() + .is_ok() +} + /// `wally help [command]`. Renders the same text `app.get_subcommand(topic) /// ->help(app.get_name())` would in C++ for a known `topic`; with no topic, /// or an unknown one, C++ falls back to `app.help()` -- which CLI11 @@ -544,7 +620,17 @@ pub fn register_help(app: &mut App) -> Rc>> { pub fn register_uninstall(app: &mut App) { let cmd = app.add_subcommand("uninstall", "Remove wally, its models and its config"); cmd.add_flag("-y,--yes", "Skip the confirmation prompt"); - cmd.callback(|parsed, options| run_uninstall(parsed.flag("--yes"), &options.home_override)); + cmd.add_flag( + "--delete-models", + "Also delete downloaded models (kept by default)", + ); + cmd.callback(|parsed, options| { + run_uninstall( + parsed.flag("--yes"), + parsed.flag("--delete-models"), + &options.home_override, + ) + }); } #[cfg(test)] diff --git a/src/commands/cmd_update.rs b/src/commands/cmd_update.rs index 38757841..61cdc383 100644 --- a/src/commands/cmd_update.rs +++ b/src/commands/cmd_update.rs @@ -6,16 +6,35 @@ //! it cannot know on its own is what the caller is running. So update hands it //! this build's version, and the script decides: pull the newer release, or //! report that this machine is already current and download nothing. +//! +//! The same script answers a check with the latest release version alone: +//! `install.sh --check`, or `install.ps1` under `WALLY_INSTALL_CHECK=1` (it is +//! run as `irm | iex`, which passes no arguments). `--check-for-updates` and the +//! one-line update-available notice both read that, so the version lookup +//! lives in one place. use crate::cli::App; use crate::io::output as out; -#[cfg(not(windows))] use crate::util::getenv; // The same script the install line in the README pipes to a shell. Kept as one // constant so the update path and the documented install path cannot drift. #[cfg(not(windows))] const INSTALL_URL: &str = "https://raw.githubusercontent.com/RunanywhereAI/wally/main/install.sh"; +#[cfg(windows)] +const INSTALL_URL: &str = "https://raw.githubusercontent.com/RunanywhereAI/wally/main/install.ps1"; + +// Points update and the update check at another copy of the installer, for +// release tests and mirrors, the way the installer's own WALLY_INSTALL_BASE_URL +// points it at another copy of the release. +const SCRIPT_URL_ENV: &str = "WALLY_UPDATE_SCRIPT_URL"; + +// How long a version lookup is trusted before the notice asks again. +const CHECK_INTERVAL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); + +// Bounds the lookup so a dead network cannot hold `--check-for-updates`, or +// leave a background check running, for long. +const CHECK_TIMEOUT_SECS: &str = "10"; // Homebrew's own prefixes, plus whatever `$HOMEBREW_PREFIX` names (set by // `brew shellenv` and by every formula's build environment). A binary @@ -48,28 +67,26 @@ pub(crate) fn is_homebrew_managed(exe: &str) -> bool { pub fn register_update(app: &mut App) { let cmd = app.add_subcommand("update", "Update wally to the latest release"); - cmd.add_flag("--nightly", "Track the development channel"); + cmd.add_flag( + "-c,--check-for-updates", + "Check for a newer release, then ask before installing it", + ); cmd.callback(|parsed, _options| { - if run_update(parsed.flag("--nightly")) != 0 { + let code = if parsed.flag("--check-for-updates") { + run_check_for_updates() + } else { + run_update() + }; + if code != 0 { return 1; } 0 }); } -/// Shared by `wally update` and the whole-argv `-u/--update` shortcut. -#[cfg(windows)] -pub fn run_update(nightly: bool) -> i32 { - let _ = nightly; - // The installer is a POSIX shell script; the Windows bottle updates - // through its own channel, not this command. - out::error_line("wally update is not available on Windows; reinstall from the release page"); - 1 -} - /// Shared by `wally update` and the whole-argv `-u/--update` shortcut. #[cfg(not(windows))] -pub fn run_update(nightly: bool) -> i32 { +pub fn run_update() -> i32 { let exe = crate::commands::cmd_maintenance::self_executable(); if is_homebrew_managed(&exe) { // Not a failure: the command's job -- get the person to the newest @@ -81,21 +98,51 @@ pub fn run_update(nightly: bool) -> i32 { return 0; } - // WALLY_VERSION is a compile-time constant and the flag is a fixed token, - // so the command line carries nothing a caller could inject. - let mut command = format!("curl -fsSL {INSTALL_URL} | sh -s --"); - if nightly { - command.push_str(" --nightly"); - } - command.push_str(" --version="); - command.push_str(env!("WALLY_VERSION")); + // The script URL and the installer's arguments reach the shell as + // positional parameters, never spliced into the command string, so an + // override URL carries nothing a caller could inject. + let installer_args = [format!("--version={}", env!("WALLY_VERSION"))]; out::status_line("checking for a newer wally..."); // A non-zero exit means the installer already explained why on its own // stderr; a failure to even spawn `sh` counts the same way. - let ok = std::process::Command::new("sh") - .arg("-c") - .arg(&command) + let ok = installer_command(&installer_args, None) + .status() + .map(|status| status.success()) + .unwrap_or(false); + if ok { + 0 + } else { + 1 + } +} + +/// Shared by `wally update` and the whole-argv `-u/--update` shortcut. +/// +/// install.ps1 replaces the running wally.exe and its DLLs file by file +/// (Windows renames a file in use but will not move or delete it), so the +/// installer runs while this process waits, the same as on macOS and Linux. +#[cfg(windows)] +pub fn run_update() -> i32 { + let current = env!("WALLY_VERSION"); + out::status_line("checking for a newer wally..."); + let Some(latest) = fetch_latest_version() else { + out::error_line("could not check for a newer wally; check your connection and try again"); + return 1; + }; + write_cached_latest(&latest); + if !is_newer(&latest, current) { + out::status_line(&format!("wally {current} is the latest")); + return 0; + } + // The URL reaches PowerShell as an environment variable, never spliced + // into the command, so an override URL carries nothing a caller could + // inject. A non-zero exit means the installer already said why. + let script = r#"$ErrorActionPreference = 'Stop' +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +Invoke-Expression (Invoke-RestMethod -UseBasicParsing $env:WALLY_UPDATE_RESOLVED_URL)"#; + let ok = powershell(script) + .env_remove("WALLY_INSTALL_CHECK") .status() .map(|status| status.success()) .unwrap_or(false); @@ -106,6 +153,297 @@ pub fn run_update(nightly: bool) -> i32 { } } +/// Deletes the `.old` files an update left beside wally.exe because +/// they were still in use. Best effort: one still in use stays for next time. +#[cfg(windows)] +pub fn sweep_replaced_files() { + let Ok(exe) = std::env::current_exe() else { + return; + }; + let Some(dir) = exe.parent() else { return }; + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + if entry.file_name().to_string_lossy().ends_with(".old") { + let _ = std::fs::remove_file(entry.path()); + } + } +} + +#[cfg(not(windows))] +pub fn sweep_replaced_files() {} + +/// `wally update --check-for-updates`: say what is out, then ask before +/// installing. Without a terminal to ask on, it reports and stops. +fn run_check_for_updates() -> i32 { + let current = env!("WALLY_VERSION"); + out::status_line("checking for a newer wally..."); + let Some(latest) = fetch_latest_version() else { + out::error_line("could not check for a newer wally; check your connection and try again"); + return 1; + }; + write_cached_latest(&latest); + if !is_newer(&latest, current) { + out::status_line(&format!("wally {current} is the latest")); + return 0; + } + out::status_line(&format!("wally {latest} is out (you have {current})")); + if !crate::util::term::stdin_is_tty() || !confirm_install() { + return 0; + } + run_update() +} + +fn confirm_install() -> bool { + use std::io::Write; + eprint!("install now? [y/N] "); + let _ = std::io::stderr().flush(); + let mut line = String::new(); + if std::io::stdin().read_line(&mut line).is_err() { + return false; + } + matches!(line.as_bytes().first(), Some(b'y') | Some(b'Y')) +} + +fn install_script_url() -> String { + getenv(SCRIPT_URL_ENV) + .filter(|url| !url.is_empty()) + .unwrap_or_else(|| INSTALL_URL.to_string()) +} + +/// `sh -c 'curl