Repository navigation
feat(models): list cloud models with --cloud and --all, add --local #163
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,15 @@ | ||
| //! `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 | ||
| //! listing. | ||
|
|
||
| use std::collections::HashMap; | ||
|
|
||
| use crate::account::{ConsoleClient, ConsoleSession}; | ||
| use crate::bootstrap::{bootstrap, GlobalOptions}; | ||
| use crate::cli::App; | ||
| use crate::commands::model_labels; | ||
|
|
@@ -209,7 +212,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<String>), | ||
| /// The console could not be reached; these are the ids the last | ||
| /// successful lookup saved (`account::cached_model_ids`). | ||
| Cached(Vec<String>), | ||
| } | ||
|
|
||
| /// 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<CloudModels, String> { | ||
| 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)) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+229
to
+250
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: set -eu
printf '%s\n' '--- diff summary ---'
git diff --stat 6e349cbd0b7d370cb2480e09374dc23c922ef853 e42eb9dc6f97bdc78823654de4e99266f46a6b39 -- src/commands/cmd_list.rs
printf '%s\n' '--- cmd_list relevant source ---'
sed -n '180,270p' src/commands/cmd_list.rs
printf '%s\n' '--- ConsoleSession definitions and uses ---'
rg -n -C 8 'struct ConsoleSession|impl ConsoleSession|fn open|fn refresh|fn call|fetch_models_within|CLOUD_LOOKUP_TIMEOUT_MS' src
printf '%s\n' '--- changed hunk ---'
git diff --unified=35 6e349cbd0b7d370cb2480e09374dc23c922ef853 e42eb9dc6f97bdc78823654de4e99266f46a6b39 -- src/commands/cmd_list.rsRepository: RunanywhereAI/wally Length of output: 41626 🤖 get_repo_knowledge executed:
Length of output: 6801 🏁 Script executed: set -eu
printf '%s\n' '--- session implementation ---'
cat -n src/account/session.rs | sed -n '1,180p'
printf '%s\n' '--- ConsoleClient declarations and model fetch implementation ---'
rg -n -C 12 'struct ConsoleClient|impl ConsoleClient|fetch_models_within|fetch_models\(' src/account src/net
printf '%s\n' '--- relevant account exports ---'
rg -n -C 5 'mod session|ConsoleSession|pub use.*session|pub use.*ConsoleClient' src/accountRepository: RunanywhereAI/wally Length of output: 20085 🏁 Script executed: set -eu
printf '%s\n' '--- refresh implementation ---'
rg -n -C 22 'pub fn refresh\(|fn refresh\(' src/account/console.rs
printf '%s\n' '--- fetch_models_within implementation ---'
cat -n src/account/console.rs | sed -n '1780,1875p'
printf '%s\n' '--- transport timeout handling ---'
rg -n -C 12 'timeout_ms|TOTAL_TIMEOUT_MS|remaining_after_discovery_ms' src/account/console.rs | head -160Repository: RunanywhereAI/wally Length of output: 14852 Bound the complete
Pass a timeout or deadline through session setup and the 401 refresh path, or document that 3 seconds applies only to the models request. 🤖 Prompt for AI Agents |
||
|
|
||
| /// 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; | ||
| }; | ||
|
|
@@ -259,7 +333,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<String> = 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(); | ||
|
|
@@ -278,14 +382,30 @@ 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(); | ||
| out::result_line(json.str()); | ||
| return 0; | ||
| } | ||
|
|
||
| print_pull_examples(); | ||
| if scope.local_rows() { | ||
| print_pull_examples(); | ||
| } | ||
|
|
||
| let mut rows: Vec<Vec<String>> = Vec::new(); | ||
| for key in &order { | ||
|
|
@@ -309,11 +429,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 <id>`" | ||
| "no models downloaded — try `wally models list --local` then `wally models pull <id>`" | ||
| }); | ||
| return 0; | ||
| } | ||
|
|
@@ -324,12 +458,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<Scope, &'static str> { | ||
| 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)] | ||
|
|
@@ -467,4 +622,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]"); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: RunanywhereAI/wally
Length of output: 4717
Do not use cached cloud models after an authentication rejection.
ConsoleSession::callreturns an authentication error after the refreshed session is rejected.cloud_modelstreats every error as a cacheable failure, so it can return stale model IDs. The caller then labels them"could not reach Wally Cloud", which misrepresents an authentication failure.Use cached IDs only for connectivity or unavailable errors. Return authentication errors unchanged.
🤖 Prompt for AI Agents