diff --git a/Cargo.lock b/Cargo.lock index f3e267cfb6..9bb2adeeed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4605,6 +4605,7 @@ dependencies = [ "comfy-table", "console", "dotenvy", + "env-lock", "etcetera 0.11.0", "futures", "goose", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 0a93aa2191..ca7ecd213d 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -108,6 +108,7 @@ native-tls = [ ] [dev-dependencies] +env-lock.workspace = true tempfile = { workspace = true } test-case = { workspace = true } tokio = { workspace = true } diff --git a/crates/goose-cli/src/session/completion.rs b/crates/goose-cli/src/session/completion.rs index f9c3d2bf67..69bffe1a7d 100644 --- a/crates/goose-cli/src/session/completion.rs +++ b/crates/goose-cli/src/session/completion.rs @@ -129,7 +129,6 @@ impl GooseCompleter { let skills = list_installed_skills(Some(&cwd)); let skill_names: Vec = skills.iter().map(|s| s.name.clone()).collect(); - // Complete the last letter being typed (e.g. "/skills coding in") let last = line.rsplit_once(' ').map_or("", |(_, w)| w); let pos = line.len() - last.len(); @@ -146,6 +145,11 @@ impl GooseCompleter { Ok((pos, candidates)) } + /// Complete model names for the /model command. + fn complete_model_names(&self, line: &str) -> Result<(usize, Vec)> { + Ok((line.len(), vec![])) + } + /// Complete slash commands fn complete_slash_commands(&self, line: &str) -> Result<(usize, Vec)> { // Define available slash commands @@ -160,6 +164,7 @@ impl GooseCompleter { "/prompts", "/prompt", "/mode", + "/model", "/recipe", "/skills", ]; @@ -396,6 +401,10 @@ impl Completer for GooseCompleter { } } + if line.starts_with("/model") { + return self.complete_model_names(line); + } + if line.starts_with("/mode") { return self.complete_mode_flags(line); } @@ -574,6 +583,20 @@ mod tests { assert_eq!(candidates.len(), 0); } + #[test] + fn test_complete_model_names() { + let cache = create_test_cache(); + let completer = GooseCompleter::new(cache); + + let (pos, candidates) = completer.complete_model_names("/model ").unwrap(); + assert_eq!(pos, "/model ".len()); + assert!(candidates.is_empty()); + + let (pos, candidates) = completer.complete_model_names("/model gpt").unwrap(); + assert_eq!(pos, "/model gpt".len()); + assert!(candidates.is_empty()); + } + #[test] fn test_complete_prompt_names() { let cache = create_test_cache(); diff --git a/crates/goose-cli/src/session/input.rs b/crates/goose-cli/src/session/input.rs index ba60ea763d..729428b294 100644 --- a/crates/goose-cli/src/session/input.rs +++ b/crates/goose-cli/src/session/input.rs @@ -20,6 +20,7 @@ pub enum InputResult { ListPrompts(Option), PromptCommand(PromptCommandOptions), GooseMode(String), + Model(Option), Plan(PlanCommandOptions), EndPlan, Clear, @@ -196,6 +197,8 @@ fn handle_slash_command(input: &str) -> Option { const CMD_EXTENSION: &str = "/extension "; const CMD_BUILTIN: &str = "/builtin "; const CMD_MODE: &str = "/mode "; + const CMD_MODEL: &str = "/model"; + const CMD_MODEL_WITH_SPACE: &str = "/model "; const CMD_PLAN: &str = "/plan"; const CMD_ENDPLAN: &str = "/endplan"; const CMD_CLEAR: &str = "/clear"; @@ -261,6 +264,19 @@ fn handle_slash_command(input: &str) -> Option { s if s.starts_with(CMD_MODE) => Some(InputResult::GooseMode( s.get(CMD_MODE.len()..).unwrap_or("").to_string(), )), + s if s == CMD_MODEL => Some(InputResult::Model(None)), + s if s.starts_with(CMD_MODEL_WITH_SPACE) => { + let model = s + .get(CMD_MODEL_WITH_SPACE.len()..) + .unwrap_or("") + .trim() + .to_string(); + if model.is_empty() { + Some(InputResult::Model(None)) + } else { + Some(InputResult::Model(Some(model))) + } + } s if s.starts_with(CMD_PLAN) => { parse_plan_command(s.get(CMD_PLAN.len()..).unwrap_or("").trim().to_string()) } @@ -399,6 +415,7 @@ fn print_help() { /prompts [--extension ] - List all available prompts, optionally filtered by extension /prompt [--info] [key=value...] - Get prompt info or execute a prompt /mode - Set the goose mode to use ({modes}) +/model [name] - Show the current model, or switch models for this session while keeping the same provider /plan - Enters 'plan' mode with optional message. Create a plan based on the current messages and asks user if they want to act on it. If user acts on the plan, goose mode is set to 'auto' and returns to 'normal' goose mode. To warm up goose before using '/plan', we recommend setting '/mode approve' & putting appropriate context into goose. @@ -498,6 +515,21 @@ mod tests { panic!("Expected AddBuiltin"); } + // Test model command + assert!(matches!( + handle_slash_command("/model"), + Some(InputResult::Model(None)) + )); + assert!(matches!( + handle_slash_command("/model "), + Some(InputResult::Model(None)) + )); + if let Some(InputResult::Model(Some(model))) = handle_slash_command("/model gpt-4.1") { + assert_eq!(model, "gpt-4.1"); + } else { + panic!("Expected Model"); + } + // Test unknown commands assert!(handle_slash_command("/unknown").is_none()); } diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 11713ed9f5..cd7e63b740 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -608,6 +608,10 @@ impl CliSession { history.save(editor); self.handle_goose_mode(&mode).await?; } + InputResult::Model(model) => { + history.save(editor); + self.handle_model(model.as_deref()).await?; + } InputResult::Plan(options) => { self.handle_plan_mode(options).await?; } @@ -795,6 +799,73 @@ impl CliSession { Ok(()) } + async fn handle_model(&self, model: Option<&str>) -> Result<()> { + let provider = self.agent.provider().await?; + let current_provider_name = provider.get_name().to_string(); + let current_model_config = provider.get_model_config(); + let current_model_name = current_model_config.model_name.clone(); + + if model.is_none() { + output::goose_mode_message(&format!( + "Current session model: '{}' (provider '{}')", + current_model_name, current_provider_name + )); + return Ok(()); + } + + let model_name = model.unwrap_or_default().trim(); + if model_name.is_empty() { + output::render_error("Model name cannot be empty"); + return Ok(()); + } + + if current_provider_name.ends_with("-acp") { + output::render_error( + "Session model switching is not supported for ACP providers in the CLI.", + ); + return Ok(()); + } + + if provider.manages_own_context() { + output::render_error(&format!( + "Session model switching is not supported for provider '{}' because it manages its own conversation context.", + current_provider_name + )); + return Ok(()); + } + + let new_model_config = + build_switched_model_config(¤t_provider_name, model_name, ¤t_model_config)?; + + if new_model_config.model_name == current_model_config.model_name + && new_model_config.thinking_effort() == current_model_config.thinking_effort() + { + output::goose_mode_message(&format!( + "Session already using model '{}' for provider '{}'", + current_model_name, current_provider_name + )); + return Ok(()); + } + + let extensions = self.agent.get_extension_configs().await; + let new_provider = + goose::providers::create(¤t_provider_name, new_model_config, extensions) + .await + .map_err(|e| anyhow::anyhow!("Failed to create provider: {e}"))?; + + self.agent + .update_provider(new_provider, &self.session_id) + .await?; + + let mode = self.agent.goose_mode().await; + self.agent.update_goose_mode(mode, &self.session_id).await?; + output::goose_mode_message(&format!( + "Session model switched from '{}' to '{}' for provider '{}'", + current_model_name, model_name, current_provider_name + )); + Ok(()) + } + async fn handle_plan_mode(&mut self, options: input::PlanCommandOptions) -> Result<()> { self.run_mode = RunMode::Plan; output::render_enter_plan_mode(); @@ -2091,11 +2162,28 @@ fn format_elapsed_time(duration: std::time::Duration) -> String { } } +fn build_switched_model_config( + provider_name: &str, + model_name: &str, + current_model_config: &goose::model::ModelConfig, +) -> Result { + goose::model::ModelConfig::new(model_name) + .map(|config| { + config + .with_canonical_limits(provider_name) + .with_temperature(current_model_config.temperature) + .with_toolshim(current_model_config.toolshim) + .with_toolshim_model(current_model_config.toolshim_model.clone()) + }) + .map_err(|e| anyhow::anyhow!("Failed to create model configuration: {e}")) +} + #[cfg(test)] mod tests { use super::*; use goose::agents::extension::Envs; use goose::config::ExtensionConfig; + use std::collections::HashMap; use std::time::Duration; use test_case::test_case; @@ -2219,6 +2307,74 @@ mod tests { assert!(CliSession::parse_stdio_extension("").is_err()); } + #[test] + fn test_build_switched_model_config_rebuilds_target_model_settings() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_TEMPERATURE", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ("GOOSE_TOOLSHIM", None::<&str>), + ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), + ]); + + let current_model_config = goose::model::ModelConfig { + model_name: "gpt-4o".to_string(), + context_limit: Some(128_000), + temperature: Some(0.25), + max_tokens: Some(16_384), + toolshim: true, + toolshim_model: Some("qwen2.5-coder".to_string()), + fast_model_config: None, + request_params: Some(HashMap::from([( + "anthropic_beta".to_string(), + serde_json::json!(["output-128k-2025-02-19"]), + )])), + reasoning: Some(false), + }; + + let switched = + build_switched_model_config("openai", "gpt-5.4", ¤t_model_config).unwrap(); + let expected = goose::model::ModelConfig::new_or_fail("gpt-5.4") + .with_canonical_limits("openai") + .with_temperature(Some(0.25)) + .with_toolshim(true) + .with_toolshim_model(Some("qwen2.5-coder".to_string())); + + assert_eq!(switched.model_name, expected.model_name); + assert_eq!(switched.context_limit, expected.context_limit); + assert_eq!(switched.max_tokens, expected.max_tokens); + assert_eq!(switched.request_params, expected.request_params); + assert_eq!(switched.reasoning, expected.reasoning); + assert_eq!(switched.temperature, Some(0.25)); + assert!(switched.toolshim); + assert_eq!(switched.toolshim_model.as_deref(), Some("qwen2.5-coder")); + } + + #[test] + fn test_build_switched_model_config_detects_effort_suffix_change() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_TEMPERATURE", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ("GOOSE_TOOLSHIM", None::<&str>), + ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), + ("GOOSE_THINKING_EFFORT", None::<&str>), + ]); + + let current = + goose::model::ModelConfig::new_or_fail("gpt-5.4-high").with_canonical_limits("openai"); + assert_eq!(current.model_name, "gpt-5.4"); + assert_eq!( + current.thinking_effort(), + Some(goose::model::ThinkingEffort::High) + ); + + let switched = build_switched_model_config("openai", "gpt-5.4", ¤t).unwrap(); + + assert_eq!(switched.model_name, current.model_name); + assert_ne!(switched.thinking_effort(), current.thinking_effort()); + } + #[test] fn test_split_command_args_windows_paths() { assert_eq!(