Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/goose-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ native-tls = [
]

[dev-dependencies]
env-lock.workspace = true
tempfile = { workspace = true }
test-case = { workspace = true }
tokio = { workspace = true }
Expand Down
25 changes: 24 additions & 1 deletion crates/goose-cli/src/session/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,6 @@ impl GooseCompleter {
let skills = list_installed_skills(Some(&cwd));
let skill_names: Vec<String> = skills.iter().map(|s| s.name.clone()).collect();

// Complete the last letter being typed (e.g. "/skills coding in<tab>")
let last = line.rsplit_once(' ').map_or("", |(_, w)| w);
let pos = line.len() - last.len();

Expand All @@ -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<Pair>)> {
Ok((line.len(), vec![]))
}

/// Complete slash commands
fn complete_slash_commands(&self, line: &str) -> Result<(usize, Vec<Pair>)> {
// Define available slash commands
Expand All @@ -160,6 +164,7 @@ impl GooseCompleter {
"/prompts",
"/prompt",
"/mode",
"/model",
"/recipe",
"/skills",
];
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();
Expand Down
32 changes: 32 additions & 0 deletions crates/goose-cli/src/session/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub enum InputResult {
ListPrompts(Option<String>),
PromptCommand(PromptCommandOptions),
GooseMode(String),
Model(Option<String>),
Plan(PlanCommandOptions),
EndPlan,
Clear,
Expand Down Expand Up @@ -196,6 +197,8 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
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";
Expand Down Expand Up @@ -261,6 +264,19 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
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())
}
Expand Down Expand Up @@ -399,6 +415,7 @@ fn print_help() {
/prompts [--extension <name>] - List all available prompts, optionally filtered by extension
/prompt <n> [--info] [key=value...] - Get prompt info or execute a prompt
/mode <name> - 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 <message_text> - 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.
Expand Down Expand Up @@ -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());
}
Expand Down
156 changes: 156 additions & 0 deletions crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
}
Expand Down Expand Up @@ -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(&current_provider_name, model_name, &current_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(&current_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();
Expand Down Expand Up @@ -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> {
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;

Expand Down Expand Up @@ -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", &current_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", &current).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!(
Expand Down
Loading