Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2ccfac9
feat(cli): /model opens a cross-provider model picker
jlmalone Jun 6, 2026
3a28adf
fix(cli): require a real credential, not a default, to offer a provider
jlmalone Jun 13, 2026
6be91f4
feat(cli): probe model before switching + quiet unavailable-provider …
jlmalone Jun 14, 2026
3025873
style(cli): rustfmt the model-picker probe block
jlmalone Jun 16, 2026
f7eaa6a
fix(cli): install a fresh provider after the model-picker probe
jlmalone Jun 16, 2026
5e857b3
Merge remote-tracking branch 'origin/main' into feat/model-cross-prov…
jlmalone Jun 17, 2026
ab9e2ae
fix(cli): guard /model picker for context-managed providers
jlmalone Jun 17, 2026
08ac2ff
Merge remote-tracking branch 'origin/main' into feat/model-cross-prov…
jlmalone Jun 22, 2026
966cb54
refactor(model-picker): share the canonical provider-configured check
jlmalone Jun 22, 2026
13b18cf
fix(providers): treat a set optional secret as a configuration signal
jlmalone Jun 22, 2026
fafb968
Merge remote-tracking branch 'origin/main' into feat/model-cross-prov…
jlmalone Jun 23, 2026
395a8ff
fix(providers): count a set secret as config with a defaulted require…
jlmalone Jun 23, 2026
e5eef0e
fix(cli): bound model-picker provider construction under the listing …
jlmalone Jun 23, 2026
544ad3b
fix(cli): show the model menu immediately with inline filter and scroll
jlmalone Jun 25, 2026
02ddfe5
fix(cli): let Esc cancel the /model picker instead of ending the session
jlmalone Jun 25, 2026
0c94765
fix(cli): make /model picker target switching unambiguous and history…
jlmalone Jun 25, 2026
3d817fc
fix(cli): use a reliable scrollable model menu instead of cliclack fi…
jlmalone Jun 25, 2026
a0d37b5
Merge origin/main and migrate the model picker to the decoupled model…
jlmalone Jun 28, 2026
d975b3e
fix(cli): let the /model picker switch to ACP targets mid-session
jlmalone Jun 28, 2026
0bc763e
fix(cli): collapse option-less ACP providers to one picker row
jlmalone Jun 28, 2026
78ab68c
fix(cli): keep option-backed ACP providers in the /model picker
jlmalone Jul 1, 2026
9c94804
Merge remote-tracking branch 'origin/main' into feat/model-cross-prov…
jlmalone Jul 1, 2026
6536ab7
fix(cli): adapt model-picker probe to Provider::complete signature
jlmalone Jul 1, 2026
d9d2493
fix(cli): improve bare /model listing and install-time construction
jlmalone Jul 1, 2026
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
2 changes: 1 addition & 1 deletion crates/goose-cli/src/commands/configure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ async fn handle_oauth_configuration(provider_name: &str, key_name: &str) -> anyh
}
}

fn interactive_model_search(models: &[String]) -> anyhow::Result<String> {
pub(crate) fn interactive_model_search(models: &[String]) -> anyhow::Result<String> {
const MAX_VISIBLE: usize = 30;
let mut query = String::new();

Expand Down
165 changes: 160 additions & 5 deletions crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -806,11 +806,13 @@ impl CliSession {
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(());
return self
.pick_and_switch_model(
&current_provider_name,
&current_model_config,
Comment on lines 838 to +842

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard bare /model for context-managed providers

When the current provider is Claude Code, Gemini CLI, or an ACP provider, this new no-argument path enters the picker before the existing provider.manages_own_context()/-acp checks below run. Those providers keep the authoritative conversation state in the subprocess, which is why /model <name> is rejected; allowing the picker to switch away from them can make the next provider continue from only Goose's partial transcript and lose hidden CLI/ACP context. Apply the same guard before opening the picker for these current providers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ab9e2ae. The -acp and manages_own_context() guards now run in handle_model before the model.is_none() dispatch, so bare /model is rejected for a context-managed current provider exactly like /model <name> already was. The picker can no longer switch away from Claude Code / Gemini CLI / an ACP provider and strand the conversation state they hold in their subprocess.

&current_model_name,
)
.await;
}

let model_name = model.unwrap_or_default().trim();
Expand Down Expand Up @@ -866,6 +868,146 @@ impl CliSession {
Ok(())
}

/// Bare `/model` (no argument): present one searchable menu of models across
/// every configured provider (local LM Studio, NVIDIA, etc.) and switch the
/// live session to the chosen provider + model.
async fn pick_and_switch_model(
&self,
current_provider_name: &str,
current_model_config: &goose::model::ModelConfig,
current_model_name: &str,
) -> Result<()> {
use std::time::Duration;

let _ = cliclack::log::info(format!(
"Current model: '{current_model_name}' (provider '{current_provider_name}'). Gathering models from your configured providers…"
));

let all = goose::providers::providers().await;
let mut entries: Vec<(String, String, String)> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();

for (meta, _ptype) in &all {
let include =
meta.name.as_str() == current_provider_name || provider_is_configured(meta);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't offer default-only providers as configured

In a normal install with no AWS credentials, this predicate still treats Amazon Bedrock as configured because its required AWS_REGION key has a built-in default in crates/goose/src/providers/bedrock.rs, and Bedrock's fetch_supported_models returns a static list without validating credentials. That makes bare /model show Bedrock models and allows switching the live session to an unusable provider, with the failure deferred until the next prompt; the picker should require an actual provider configuration/credential signal rather than counting defaults alone.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3a28adf. A built-in default is now treated as configuration, not a credential: a provider that authenticates with a secret (API key / bearer token) must have at least one real secret value to be offered in /model — so Bedrock no longer appears merely because AWS_REGION has a default. Providers with no secret keys (LM Studio, Ollama, and other local no-auth backends) declare no secrets and stay always available. Added a regression test covering the Bedrock-like, local, and credentialed cases.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include OAuth-token providers in picker

When a user has already signed in to an OAuth-backed provider such as ChatGPT Codex or xAI OAuth and the current session is on a different provider, the usable credential lives in the provider's token cache rather than in Config::global() under the *_TOKEN metadata key; for example, ChatGptCodexProvider::inventory_configured() checks TokenCache::new().load().is_some(). This metadata-only predicate therefore returns false and bare /model omits an actually configured provider from the cross-provider picker, so users cannot switch to it despite being authenticated. Use the registry entry's inventory_configured() or equivalent provider-specific status instead of only ProviderMetadata keys here.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real gap, but it belongs in the per-provider follow-up (#9822) rather than this metadata predicate. The usable credential for OAuth-backed providers lives in their token cache, which only inventory_configured() (e.g. ChatGptCodexProvider's TokenCache::new().load().is_some()) can see. The shared check_provider_configured works from ProviderMetadata keys and structurally can't observe a provider's token cache. Gating the picker on each registry entry's inventory_configured() is the fix, and it closes this together with the other provider-specific cases.

if !include || !seen.insert(meta.name.clone()) {
continue;
}

let seed = if meta.default_model.trim().is_empty() {
"placeholder".to_string()
} else {
meta.default_model.clone()
};
let model_config = match goose::model::ModelConfig::new(&seed) {
Ok(c) => c.with_canonical_limits(&meta.name),
Err(_) => continue,
};
let temp_provider = match goose::providers::create(&meta.name, model_config, Vec::new())
.await
{
Ok(p) => p,
Err(e) => {
let _ =
cliclack::log::warning(format!("Skipping '{}': {e}", meta.display_name));
continue;
}
};

match tokio::time::timeout(
Duration::from_secs(15),
temp_provider.fetch_supported_models(),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid timing out subprocess model fetches without cleanup

When a configured Claude Code provider is installed but its model_list control request hangs or exceeds 15s, this timeout cancels ClaudeCodeProvider::fetch_supported_models while it is waiting; that function spawned a child from build_stream_json_command() and only calls child.kill().await after exchange_control returns, with no kill_on_drop, so the timed-out /model picker can leave a claude process running. Either make subprocess model listing cancellation-safe or avoid wrapping it in this timeout.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. When the 15s list timeout cancels fetch_supported_models() for a Claude Code provider, the spawned claude child can outlive it because the command isn't kill_on_drop. The picker can't reap a child it doesn't own, so the durable fix belongs in ClaudeCodeProvider (kill_on_drop(true) on the command, or a cancellation-safe control request) rather than wrapping it differently from the picker side. Tracked in #9822.

.await
{
Ok(Ok(models)) if !models.is_empty() => {
for m in models {
let mut label = format!("{} ▸ {}", meta.display_name, m);
if meta.name.as_str() == current_provider_name
&& m.as_str() == current_model_name
{
label.push_str(" (current)");
}
entries.push((label, meta.name.clone(), m));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't offer ACP models that cannot be applied

When models comes from an ACP target whose wrapper does not set a model config option, this adds every reported ACP model as a selectable row even though the selected model is never applied. For example, claude-acp sets model_config_option_id: None in crates/goose/src/providers/claude_acp.rs:85, and AcpProvider::apply_model_if_changed returns immediately without an option id in crates/goose/src/acp/provider.rs:334-337; selecting Claude Code ▸ sonnet from bare /model therefore persists sonnet in the Goose session while the ACP subprocess stays on its default/current model. Only expose a sentinel/current row for ACP providers without a model option, or wire the option id before offering per-model choices.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0bc763e6b. You're right that the per-model rows were misleading for an ACP target that can't apply a model choice: apply_model_if_changed returns early when model_config_option_id is None, so the pick was persisted goose-side while the agent stayed on its own current model.

The picker now asks each provider whether a model pick is meaningful via a new Provider::applies_selected_model() (default true; AcpProvider returns self.model_config_option_id.is_some()). When it's false (Claude Code, Amp, Codex, Pi) the provider gets a single … ▸ (agent's current model) row that switches via the ACP_CURRENT_MODEL sentinel, so nothing is pinned that the agent won't honor. Copilot, which does set model_config_option_id, keeps its full per-model list. Added a unit test for the predicate.

}
}
Ok(Ok(_)) => {
let _ = cliclack::log::warning(format!(
"'{}' returned no models",
meta.display_name
));
}
Ok(Err(e)) => {
let _ = cliclack::log::warning(format!(
"Could not list models for '{}': {e}",
meta.display_name
));
}
Err(_) => {
let _ = cliclack::log::warning(format!(
"Timed out listing models for '{}'",
meta.display_name
));
}
}
}

if entries.is_empty() {
output::render_error(
"No models available from any configured provider. Check that LM Studio is running or that provider keys are set.",
);
return Ok(());
}

entries.sort_by(|a, b| {
let a_current = a.1.as_str() == current_provider_name;
let b_current = b.1.as_str() == current_provider_name;
b_current
.cmp(&a_current)
.then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))
});

let labels: Vec<String> = entries.iter().map(|(l, _, _)| l.clone()).collect();
let chosen_label: String = if labels.len() > 12 {
crate::commands::configure::interactive_model_search(&labels)?
} else {
let items: Vec<(String, String, &str)> =
labels.iter().map(|l| (l.clone(), l.clone(), "")).collect();
cliclack::select("Select a model:")
.items(&items)
.interact()?
};

let (_, chosen_provider, chosen_model) =
match entries.into_iter().find(|(l, _, _)| l == &chosen_label) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use unique values for picker entries

When two rows render the same label, such as a custom/declarative provider using the same display name and model id as another provider, cliclack returns only that label string and this lookup always picks the first matching entry. In that scenario selecting the later row switches to the wrong provider/model; use a stable unique item value (or include the provider name in the value/label) so the selected row maps back to the intended entry.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0c9476567. cliclack returns only the selected label, so two entries with the same display name and model id mapped back ambiguously and the lookup took the first. The picker now disambiguates any colliding label with the internal provider name before the menu is shown, so each row resolves to exactly one (provider, model). Non-colliding labels are unchanged.

Some(e) => e,
None => return Ok(()),
};

if chosen_provider.as_str() == current_provider_name
&& chosen_model.as_str() == current_model_name
{
output::goose_mode_message(&format!("Session already using model '{chosen_model}'"));
return Ok(());
}

let new_model_config =
build_switched_model_config(&chosen_provider, &chosen_model, current_model_config)?;
let extensions = self.agent.get_extension_configs().await;
let new_provider = goose::providers::create(&chosen_provider, 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 to '{chosen_model}' (provider '{chosen_provider}')"
));
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 @@ -2162,6 +2304,19 @@ fn format_elapsed_time(duration: std::time::Duration) -> String {
}
}

/// A provider is offered in the `/model` menu when all of its required config
/// keys are resolvable (via env var, stored secret/param, or a built-in default).
/// This keeps unconfigured cloud providers out of the list while always including
/// local (no-auth) providers like LM Studio and any provider whose key is set.
fn provider_is_configured(meta: &goose::providers::base::ProviderMetadata) -> bool {
let config = Config::global();
meta.config_keys.iter().filter(|k| k.required).all(|k| {
std::env::var(&k.name).is_ok()
|| k.default.is_some()
|| config.get(&k.name, k.secret).is_ok()
})
}

fn build_switched_model_config(
provider_name: &str,
model_name: &str,
Expand Down