Structured per-provider config block, non-destructive provider switching (#8977)

Signed-off-by: Douwe Osinga <douwe@squareup.com>
Signed-off-by: Aaron Yourk <ayourk@gmail.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
ayourk
2026-05-18 18:22:50 +00:00
committed by GitHub
parent 60c482de25
commit badb97a01e
26 changed files with 1014 additions and 95 deletions
+1 -2
View File
@@ -853,8 +853,7 @@ pub async fn configure_provider_dialog() -> anyhow::Result<bool> {
match test_provider_configuration(provider_name, &model, toolshim_enabled, toolshim_model).await
{
Ok(()) => {
config.set_goose_provider(provider_name)?;
config.set_goose_model(&model)?;
goose::config::set_active_provider(config, provider_name, &model)?;
print_config_file_saved()?;
Ok(true)
}
@@ -69,6 +69,8 @@ pub struct ProviderDetails {
pub metadata: ProviderMetadata,
pub is_configured: bool,
pub provider_type: ProviderType,
#[serde(skip_serializing_if = "Option::is_none")]
pub saved_model: Option<String>,
}
#[derive(Serialize, ToSchema)]
@@ -171,6 +173,29 @@ pub async fn upsert_config(
Json(query): Json<UpsertConfigQuery>,
) -> Result<Json<Value>, ErrorResponse> {
let config = Config::global();
// Intercept legacy keys to write structured provider config
if query.key == "GOOSE_PROVIDER" {
if let Some(name) = query.value.as_str() {
// Preserve the target provider's saved model rather than copying
// the current active provider's model into the new entry.
let model = goose::config::get_provider_entry(config, name)
.map(|e| e.model)
.or_else(|| config.get_goose_model().ok())
.unwrap_or_default();
goose::config::set_active_provider(config, name, &model)?;
return Ok(Json(Value::String(format!("Upserted key {}", query.key))));
}
}
if query.key == "GOOSE_MODEL" {
if let Some(model) = query.value.as_str() {
if let Ok(provider) = config.get_goose_provider() {
goose::config::set_active_provider(config, &provider, model)?;
return Ok(Json(Value::String(format!("Upserted key {}", query.key))));
}
}
}
config.set(&query.key, &query.value, query.is_secret)?;
Ok(Json(Value::String(format!("Upserted key {}", query.key))))
}
@@ -192,6 +217,14 @@ pub async fn remove_config(
if query.is_secret {
config.delete_secret(&query.key)?;
} else if query.key == "GOOSE_PROVIDER" || query.key == "active_provider" {
config.delete("active_provider")?;
config.delete("GOOSE_PROVIDER")?;
} else if query.key == "GOOSE_MODEL" {
if let Ok(provider) = config.get_goose_provider() {
goose::config::set_active_provider(config, &provider, "")?;
}
config.delete("GOOSE_MODEL")?;
} else {
config.delete(&query.key)?;
}
@@ -236,6 +269,20 @@ pub async fn read_config(
) -> Result<Json<ConfigValueResponse>, ErrorResponse> {
let config = Config::global();
// Intercept legacy keys to return structured provider config
if query.key == "GOOSE_PROVIDER" || query.key == "active_provider" {
if let Ok(val) = config.get_goose_provider() {
return Ok(Json(ConfigValueResponse::Value(Value::String(val))));
}
return Ok(Json(ConfigValueResponse::Value(Value::Null)));
}
if query.key == "GOOSE_MODEL" {
if let Ok(val) = config.get_goose_model() {
return Ok(Json(ConfigValueResponse::Value(Value::String(val))));
}
return Ok(Json(ConfigValueResponse::Value(Value::Null)));
}
let response_value = match config.get(&query.key, query.is_secret) {
Ok(value) => {
if query.is_secret {
@@ -341,17 +388,22 @@ pub async fn read_all_config() -> Result<Json<ConfigResponse>, ErrorResponse> {
)
)]
pub async fn providers() -> Result<Json<Vec<ProviderDetails>>, ErrorResponse> {
let config = Config::global();
let providers = get_providers().await;
let providers_response: Vec<ProviderDetails> = providers
.into_iter()
.map(|(metadata, provider_type)| {
let is_configured = check_provider_configured(&metadata, provider_type);
let saved_model = goose::config::get_provider_entry(config, &metadata.name)
.map(|e| e.model)
.filter(|m| !m.is_empty());
ProviderDetails {
name: metadata.name.clone(),
metadata,
is_configured,
provider_type,
saved_model,
}
})
.collect();
@@ -729,9 +781,7 @@ pub async fn set_config_provider(
.await
.and_then(|_| {
let config = Config::global();
config
.set_goose_provider(provider.clone())
.and_then(|_| config.set_goose_model(model.clone()))
goose::config::set_active_provider(config, &provider, &model)
.map_err(|e| anyhow::anyhow!(e))
})
.map_err(|err| {
@@ -839,9 +889,28 @@ pub async fn configure_provider_oauth(
})?;
// Mark the provider as configured after successful OAuth
let configured_marker = format!("{}_configured", provider_name);
let config = goose::config::Config::global();
config.set_param(&configured_marker, true)?;
if let Some(mut entry) = goose::config::get_provider_entry(config, &provider_name) {
entry.configured = true;
goose::config::set_provider_entry(config, &provider_name, &entry)?;
} else {
let model = if goose::config::get_active_provider(config).as_deref()
== Some(provider_name.as_str())
{
config.get_goose_model().unwrap_or_default()
} else {
String::new()
};
goose::config::set_provider_entry(
config,
&provider_name,
&goose::config::ProviderEntry {
enabled: true,
model,
configured: true,
},
)?;
}
Ok(Json("OAuth configuration completed".to_string()))
}
+21 -4
View File
@@ -118,20 +118,37 @@ pub fn check_provider_configured(metadata: &ProviderMetadata, provider_type: Pro
}
}
// Special case: OAuth providers - check for configured marker
// OAuth providers: trust the structured configured flag or legacy marker
let has_oauth_key = metadata.config_keys.iter().any(|key| key.oauth_flow);
if has_oauth_key {
if let Some(entry) = goose::config::get_provider_entry(config, &metadata.name) {
if entry.configured {
return true;
}
}
let configured_marker = format!("{}_configured", metadata.name);
if matches!(config.get_param::<bool>(&configured_marker), Ok(true)) {
return true;
}
}
// Special case: Zero-config providers (no config keys)
// Zero-config providers (no config keys): trust structured flag or active status
if metadata.config_keys.is_empty() {
// Check if the provider has been explicitly configured via the UI
if let Some(entry) = goose::config::get_provider_entry(config, &metadata.name) {
if entry.configured {
return true;
}
}
let configured_marker = format!("{}_configured", metadata.name);
return config.get_param::<bool>(&configured_marker).is_ok();
if config.get_param::<bool>(&configured_marker).is_ok() {
return true;
}
if let Ok(current) = config.get_goose_provider() {
if current == metadata.name {
return true;
}
}
return false;
}
// Get all required keys
+8 -27
View File
@@ -63,8 +63,8 @@ impl GooseAcpAgent {
) -> Result<DefaultsReadResponse, agent_client_protocol::Error> {
let config = self.config()?;
Ok(DefaultsReadResponse {
provider_id: optional_config_string(&config, "GOOSE_PROVIDER")?,
model_id: optional_config_string(&config, "GOOSE_MODEL")?,
provider_id: config.get_goose_provider().ok(),
model_id: config.get_goose_model().ok(),
})
}
@@ -113,21 +113,13 @@ impl GooseAcpAgent {
}
let config = self.config()?;
config
.set_param_values(&[(
"GOOSE_PROVIDER".to_string(),
serde_json::Value::String(provider_id.clone()),
)])
let model = model_id.clone().unwrap_or_else(|| {
crate::config::get_provider_entry(&config, &provider_id)
.map(|e| e.model)
.unwrap_or_default()
});
crate::config::set_active_provider(&config, &provider_id, &model)
.internal_err_ctx("Failed to save default provider")?;
if let Some(model_id) = model_id.as_deref() {
config
.set_param("GOOSE_MODEL", model_id)
.internal_err_ctx("Failed to save default model")?;
} else {
config
.delete("GOOSE_MODEL")
.internal_err_ctx("Failed to clear default model")?;
}
Ok(DefaultsReadResponse {
provider_id: Some(provider_id),
@@ -245,14 +237,3 @@ fn is_supported_voice_dictation_provider(value: &str) -> bool {
}
}
}
fn optional_config_string(
config: &Config,
key: &str,
) -> Result<Option<String>, agent_client_protocol::Error> {
match config.get_param::<String>(key) {
Ok(value) => Ok(Some(value)),
Err(crate::config::ConfigError::NotFound(_)) => Ok(None),
Err(e) => Err(agent_client_protocol::Error::internal_error().data(e.to_string())),
}
}
+29 -17
View File
@@ -324,20 +324,15 @@ fn apply_goose_config_candidate(
let provider = yaml_string(&source, "GOOSE_PROVIDER");
let model = yaml_string(&source, "GOOSE_MODEL");
if provider.is_some() || model.is_some() {
let mut updates = Vec::new();
if let Some(provider) = provider.clone() {
updates.push((
"GOOSE_PROVIDER".to_string(),
serde_json::Value::String(provider),
));
}
if let Some(model) = model.clone() {
updates.push(("GOOSE_MODEL".to_string(), serde_json::Value::String(model)));
}
target_config.set_param_values(&updates)?;
if let Some(ref p) = provider {
let m = model.clone().unwrap_or_else(|| {
crate::config::get_provider_entry(target_config, p)
.map(|e| e.model)
.unwrap_or_default()
});
crate::config::set_active_provider(target_config, p, &m)?;
result.provider_defaults = DefaultsReadResponse {
provider_id: provider,
provider_id: provider.clone(),
model_id: model,
};
result.imported.providers = 1;
@@ -669,13 +664,30 @@ extensions:
assert_eq!(result.imported.providers, 1);
assert_eq!(result.imported.extensions, 1);
assert_eq!(result.imported.skills, 1);
assert_eq!(
target_config.get_param::<String>("GOOSE_PROVIDER").unwrap(),
"openai"
);
assert_eq!(target_config.get_goose_provider().unwrap(), "openai");
assert!(target.path().join("skills").join("reviewer").exists());
}
#[test]
fn apply_goose_config_model_only_skips_provider_activation() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let source_config = source.path().join(CONFIG_YAML_NAME);
fs::write(&source_config, "GOOSE_MODEL: gpt-5.1\n").unwrap();
let target_config = Config::new_with_file_secrets(
target.path().join(CONFIG_YAML_NAME),
target.path().join("secrets.yaml"),
)
.unwrap();
let result =
apply_goose_config_candidate(&target_config, target.path(), &source_config).unwrap();
assert_eq!(result.imported.providers, 0);
assert!(target_config.get_goose_provider().is_err());
}
#[cfg(unix)]
#[test]
fn import_skill_dirs_skips_symlink_cycles() {
+1 -6
View File
@@ -613,12 +613,7 @@ impl GooseAcpAgent {
.data(format!("Provider is not editable: {}", req.provider_id)));
}
if Config::global()
.get_param::<String>("GOOSE_PROVIDER")
.ok()
.as_deref()
== Some(req.provider_id.as_str())
{
if Config::global().get_goose_provider().ok().as_deref() == Some(req.provider_id.as_str()) {
return Err(agent_client_protocol::Error::invalid_params().data(format!(
"Cannot delete active provider: {}",
req.provider_id
+194 -12
View File
@@ -281,9 +281,11 @@ fn keyring_disabled_value(value: &serde_yaml::Value) -> bool {
}
const EXTENSIONS_KEY: &str = "extensions";
const PROVIDERS_KEY: &str = "providers";
pub fn merge_config_values(base: &mut Mapping, overlay: Mapping) {
let extensions_key = serde_yaml::Value::String(EXTENSIONS_KEY.to_string());
let providers_key = serde_yaml::Value::String(PROVIDERS_KEY.to_string());
for (key, overlay_value) in overlay {
if key == extensions_key {
@@ -293,7 +295,18 @@ pub fn merge_config_values(base: &mut Mapping, overlay: Mapping) {
if let (Some(base_map), Some(overlay_map)) =
(base_ext.as_mapping_mut(), overlay_value.as_mapping())
{
merge_extensions(base_map, overlay_map);
merge_nested_entries(base_map, overlay_map);
} else {
base.insert(key, overlay_value);
}
} else if key == providers_key {
let base_prov = base
.entry(key.clone())
.or_insert_with(|| serde_yaml::Value::Mapping(Mapping::new()));
if let (Some(base_map), Some(overlay_map)) =
(base_prov.as_mapping_mut(), overlay_value.as_mapping())
{
merge_nested_entries(base_map, overlay_map);
} else {
base.insert(key, overlay_value);
}
@@ -303,7 +316,7 @@ pub fn merge_config_values(base: &mut Mapping, overlay: Mapping) {
}
}
fn merge_extensions(base: &mut Mapping, overlay: &Mapping) {
fn merge_nested_entries(base: &mut Mapping, overlay: &Mapping) {
for (ext_key, overlay_ext) in overlay {
match base.get_mut(ext_key) {
Some(base_ext) => {
@@ -471,20 +484,27 @@ impl Config {
}
}
crate::config::migrations::run_migrations(&mut merged);
crate::config::migrations::run_read_migrations(&mut merged);
Ok(merged)
}
pub fn all_values(&self) -> Result<HashMap<String, Value>, ConfigError> {
let config_values = self.load()?;
Ok(HashMap::from_iter(config_values.into_iter().filter_map(
|(k, v)| {
k.as_str()
.map(|k| k.to_string())
.zip(serde_json::to_value(v).ok())
},
)))
let mut map = HashMap::from_iter(config_values.into_iter().filter_map(|(k, v)| {
k.as_str()
.map(|k| k.to_string())
.zip(serde_json::to_value(v).ok())
}));
if let Ok(provider) = self.get_goose_provider() {
map.insert("GOOSE_PROVIDER".to_string(), Value::String(provider));
}
if let Ok(model) = self.get_goose_model() {
map.insert("GOOSE_MODEL".to_string(), Value::String(model));
}
Ok(map)
}
fn config_write_target_path(&self) -> Result<PathBuf, ConfigError> {
@@ -703,6 +723,24 @@ impl Config {
}
}
/// Read-modify-write a configuration value atomically through the write path.
pub fn update_param<T, V, F>(&self, key: &str, f: F) -> Result<(), ConfigError>
where
T: for<'de> Deserialize<'de> + Default,
V: Serialize,
F: FnOnce(T) -> V,
{
let _guard = self.guard.lock().unwrap();
let mut values = self.load_write_config()?;
let current: T = values
.get(key)
.and_then(|v| serde_yaml::from_value(v.clone()).ok())
.unwrap_or_default();
let updated = f(current);
values.insert(serde_yaml::to_value(key)?, serde_yaml::to_value(updated)?);
self.save_values(&values)
}
/// Set a configuration value in the config file (non-secret).
///
/// This will immediately write the value to the config file. The value
@@ -1031,8 +1069,33 @@ config_value!(CHATGPT_CODEX_REASONING_EFFORT, String, "medium");
config_value!(GOOSE_SEARCH_PATHS, Vec<String>);
config_value!(GOOSE_MODE, GooseMode);
config_value!(GOOSE_PROVIDER, String);
config_value!(GOOSE_MODEL, String);
// GOOSE_PROVIDER and GOOSE_MODEL are handled by crate::config::providers
// which checks the structured `providers:` block first and falls back to
// the legacy flat keys. The accessors below delegate to that module.
impl Config {
pub fn get_goose_provider(&self) -> Result<String, ConfigError> {
crate::config::providers::get_active_provider(self)
.ok_or_else(|| ConfigError::NotFound("GOOSE_PROVIDER".to_string()))
}
pub fn set_goose_provider(&self, v: impl Into<String>) -> Result<(), ConfigError> {
let name = v.into();
let model = crate::config::providers::get_provider_entry(self, &name)
.map(|e| e.model)
.unwrap_or_default();
crate::config::providers::set_active_provider(self, &name, &model)
}
pub fn get_goose_model(&self) -> Result<String, ConfigError> {
crate::config::providers::get_active_model(self)
.ok_or_else(|| ConfigError::NotFound("GOOSE_MODEL".to_string()))
}
pub fn set_goose_model(&self, v: impl Into<String>) -> Result<(), ConfigError> {
let model = v.into();
if let Some(provider) = crate::config::providers::get_active_provider(self) {
crate::config::providers::set_active_provider(self, &provider, &model)?;
}
Ok(())
}
}
config_value!(GOOSE_PROMPT_EDITOR, Option<String>);
config_value!(GOOSE_PROMPT_EDITOR_ALWAYS, Option<bool>);
config_value!(GOOSE_MAX_ACTIVE_AGENTS, usize);
@@ -2145,4 +2208,123 @@ extensions:
Ok(())
}
#[test]
fn test_merge_providers_append_new() {
let mut base = Mapping::new();
let mut base_prov = Mapping::new();
let mut prov_a = Mapping::new();
prov_a.insert(
serde_yaml::Value::String("enabled".into()),
serde_yaml::Value::Bool(true),
);
prov_a.insert(
serde_yaml::Value::String("model".into()),
serde_yaml::Value::String("gpt-4o".into()),
);
prov_a.insert(
serde_yaml::Value::String("configured".into()),
serde_yaml::Value::Bool(true),
);
base_prov.insert(
serde_yaml::Value::String("openai".into()),
serde_yaml::Value::Mapping(prov_a),
);
base.insert(
serde_yaml::Value::String("providers".into()),
serde_yaml::Value::Mapping(base_prov),
);
let mut overlay = Mapping::new();
let mut overlay_prov = Mapping::new();
let mut prov_b = Mapping::new();
prov_b.insert(
serde_yaml::Value::String("enabled".into()),
serde_yaml::Value::Bool(true),
);
prov_b.insert(
serde_yaml::Value::String("model".into()),
serde_yaml::Value::String("claude-3-opus".into()),
);
prov_b.insert(
serde_yaml::Value::String("configured".into()),
serde_yaml::Value::Bool(true),
);
overlay_prov.insert(
serde_yaml::Value::String("anthropic".into()),
serde_yaml::Value::Mapping(prov_b),
);
overlay.insert(
serde_yaml::Value::String("providers".into()),
serde_yaml::Value::Mapping(overlay_prov),
);
merge_config_values(&mut base, overlay);
let providers = base.get("providers").unwrap().as_mapping().unwrap();
assert!(providers.contains_key("openai"));
assert!(providers.contains_key("anthropic"));
// openai should be unchanged
let a = providers.get("openai").unwrap().as_mapping().unwrap();
assert!(a.get("enabled").unwrap().as_bool().unwrap());
assert_eq!(a.get("model").unwrap().as_str().unwrap(), "gpt-4o");
}
#[test]
fn test_merge_providers_partial_override() {
let mut base = Mapping::new();
let mut base_prov = Mapping::new();
let mut prov = Mapping::new();
prov.insert(
serde_yaml::Value::String("enabled".into()),
serde_yaml::Value::Bool(true),
);
prov.insert(
serde_yaml::Value::String("model".into()),
serde_yaml::Value::String("gpt-4o".into()),
);
prov.insert(
serde_yaml::Value::String("configured".into()),
serde_yaml::Value::Bool(true),
);
base_prov.insert(
serde_yaml::Value::String("openai".into()),
serde_yaml::Value::Mapping(prov),
);
base.insert(
serde_yaml::Value::String("providers".into()),
serde_yaml::Value::Mapping(base_prov),
);
// Overlay just changes the model
let mut overlay = Mapping::new();
let mut overlay_prov = Mapping::new();
let mut prov_override = Mapping::new();
prov_override.insert(
serde_yaml::Value::String("model".into()),
serde_yaml::Value::String("gpt-4o-mini".into()),
);
overlay_prov.insert(
serde_yaml::Value::String("openai".into()),
serde_yaml::Value::Mapping(prov_override),
);
overlay.insert(
serde_yaml::Value::String("providers".into()),
serde_yaml::Value::Mapping(overlay_prov),
);
merge_config_values(&mut base, overlay);
let providers = base.get("providers").unwrap().as_mapping().unwrap();
let openai = providers.get("openai").unwrap().as_mapping().unwrap();
// model should be overridden
assert_eq!(
openai.get("model").unwrap().as_str().unwrap(),
"gpt-4o-mini"
);
// Other fields should be preserved
assert!(openai.get("enabled").unwrap().as_bool().unwrap());
assert!(openai.get("configured").unwrap().as_bool().unwrap());
}
}
+377
View File
@@ -1,16 +1,27 @@
use crate::agents::extension::PLATFORM_EXTENSIONS;
use crate::agents::ExtensionConfig;
use crate::config::extensions::ExtensionEntry;
use crate::config::providers::ProviderEntry;
use serde_yaml::Mapping;
const EXTENSIONS_CONFIG_KEY: &str = "extensions";
const PROVIDERS_CONFIG_KEY: &str = "providers";
const ACTIVE_PROVIDER_KEY: &str = "active_provider";
pub fn run_migrations(config: &mut Mapping) -> bool {
let mut changed = false;
changed |= migrate_platform_extensions(config);
changed |= migrate_provider_config(config);
changed
}
/// Run only non-destructive migrations suitable for in-memory read paths.
/// Provider migration is excluded because it removes flat keys that
/// `get_param()` callers may still look up directly.
pub fn run_read_migrations(config: &mut Mapping) {
migrate_platform_extensions(config);
}
fn migrate_platform_extensions(config: &mut Mapping) -> bool {
let extensions_key = serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string());
@@ -102,6 +113,153 @@ fn migrate_platform_extensions(config: &mut Mapping) -> bool {
needs_save
}
/// Remove leftover legacy flat keys when `providers:` block already exists.
fn cleanup_legacy_provider_keys(config: &mut Mapping) -> bool {
let configured_suffix = "_configured";
let mut changed = false;
let stale_keys: Vec<serde_yaml::Value> = config
.keys()
.filter(|k| {
k.as_str()
.map(|s| {
s == "GOOSE_PROVIDER" || s == "GOOSE_MODEL" || s.ends_with(configured_suffix)
})
.unwrap_or(false)
})
.cloned()
.collect();
for key in stale_keys {
config.shift_remove(&key);
changed = true;
}
changed
}
/// Migrate flat provider keys to the structured `providers:` block.
///
/// Old layout (flat keys):
/// ```yaml
/// GOOSE_PROVIDER: claude-acp
/// GOOSE_MODEL: current
/// claude-acp_configured: true
/// lmstudio_configured: true
/// ```
///
/// New layout:
/// ```yaml
/// active_provider: claude-acp
/// providers:
/// claude-acp:
/// enabled: true
/// model: current
/// configured: true
/// lmstudio:
/// enabled: true
/// model: ""
/// configured: true
/// ```
///
fn migrate_provider_config(config: &mut Mapping) -> bool {
let providers_key = serde_yaml::Value::String(PROVIDERS_CONFIG_KEY.to_string());
// If providers block already exists, backfill active_provider from the
// legacy flat key when missing, then clean up leftover flat keys.
if config.contains_key(&providers_key) {
let ap_key = serde_yaml::Value::String(ACTIVE_PROVIDER_KEY.to_string());
if !config.contains_key(&ap_key) {
if let Some(legacy) = config
.get(serde_yaml::Value::String("GOOSE_PROVIDER".to_string()))
.and_then(|v| v.as_str())
{
config.insert(ap_key, serde_yaml::Value::String(legacy.to_string()));
}
}
return cleanup_legacy_provider_keys(config);
}
// Read the old flat keys, if present.
let active_provider = config
.get(serde_yaml::Value::String("GOOSE_PROVIDER".to_string()))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let active_model = config
.get(serde_yaml::Value::String("GOOSE_MODEL".to_string()))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
// Scan for `*_configured` keys to discover all previously-used providers.
let configured_suffix = "_configured";
let mut discovered_providers: Vec<String> = config
.keys()
.filter_map(|k| {
k.as_str().and_then(|s| {
if s.ends_with(configured_suffix) {
Some(s.trim_end_matches(configured_suffix).to_string())
} else {
None
}
})
})
.collect();
// Ensure the active provider is in the list even if no `*_configured`
// marker exists for it yet.
if let Some(ref ap) = active_provider {
if !discovered_providers.contains(ap) {
discovered_providers.push(ap.clone());
}
}
// If there is nothing to migrate, bail out.
if discovered_providers.is_empty() && active_provider.is_none() {
return false;
}
// Build the providers mapping.
let mut providers_map = Mapping::new();
for name in &discovered_providers {
let is_active = active_provider.as_deref() == Some(name.as_str());
let model = if is_active {
active_model.clone()
} else {
String::new()
};
let entry = ProviderEntry {
enabled: true,
model,
configured: true,
};
if let Ok(value) = serde_yaml::to_value(&entry) {
providers_map.insert(serde_yaml::Value::String(name.clone()), value);
}
}
config.insert(providers_key, serde_yaml::Value::Mapping(providers_map));
// Write `active_provider` top-level key.
if let Some(ref ap) = active_provider {
config.insert(
serde_yaml::Value::String(ACTIVE_PROVIDER_KEY.to_string()),
serde_yaml::Value::String(ap.clone()),
);
}
// Remove old flat keys.
config.shift_remove(serde_yaml::Value::String("GOOSE_PROVIDER".to_string()));
config.shift_remove(serde_yaml::Value::String("GOOSE_MODEL".to_string()));
for name in &discovered_providers {
let marker_key = serde_yaml::Value::String(format!("{}{}", name, configured_suffix));
config.shift_remove(&marker_key);
}
true
}
#[cfg(test)]
mod tests {
use super::*;
@@ -159,4 +317,223 @@ mod tests {
let changed = run_migrations(&mut config);
assert!(!changed);
}
// -----------------------------------------------------------------------
// Provider migration tests
// -----------------------------------------------------------------------
#[test]
fn test_migrate_provider_config_basic() {
let mut config = Mapping::new();
config.insert(
serde_yaml::Value::String("GOOSE_PROVIDER".to_string()),
serde_yaml::Value::String("claude-acp".to_string()),
);
config.insert(
serde_yaml::Value::String("GOOSE_MODEL".to_string()),
serde_yaml::Value::String("current".to_string()),
);
config.insert(
serde_yaml::Value::String("claude-acp_configured".to_string()),
serde_yaml::Value::Bool(true),
);
let changed = migrate_provider_config(&mut config);
assert!(changed);
// active_provider should be set
let active = config
.get(serde_yaml::Value::String("active_provider".to_string()))
.unwrap()
.as_str()
.unwrap();
assert_eq!(active, "claude-acp");
// providers block should exist with the entry
let providers = config
.get(serde_yaml::Value::String("providers".to_string()))
.unwrap()
.as_mapping()
.unwrap();
let entry: ProviderEntry = serde_yaml::from_value(
providers
.get(serde_yaml::Value::String("claude-acp".to_string()))
.unwrap()
.clone(),
)
.unwrap();
assert!(entry.enabled);
assert!(entry.configured);
assert_eq!(entry.model, "current");
// Old flat keys should be removed
assert!(!config.contains_key(serde_yaml::Value::String("GOOSE_PROVIDER".to_string())));
assert!(!config.contains_key(serde_yaml::Value::String("GOOSE_MODEL".to_string())));
assert!(!config.contains_key(serde_yaml::Value::String(
"claude-acp_configured".to_string()
)));
}
#[test]
fn test_migrate_provider_config_multiple_configured() {
let mut config = Mapping::new();
config.insert(
serde_yaml::Value::String("GOOSE_PROVIDER".to_string()),
serde_yaml::Value::String("claude-acp".to_string()),
);
config.insert(
serde_yaml::Value::String("GOOSE_MODEL".to_string()),
serde_yaml::Value::String("current".to_string()),
);
config.insert(
serde_yaml::Value::String("claude-acp_configured".to_string()),
serde_yaml::Value::Bool(true),
);
config.insert(
serde_yaml::Value::String("lmstudio_configured".to_string()),
serde_yaml::Value::Bool(true),
);
let changed = migrate_provider_config(&mut config);
assert!(changed);
let providers = config
.get(serde_yaml::Value::String("providers".to_string()))
.unwrap()
.as_mapping()
.unwrap();
// Both providers should exist
let claude: ProviderEntry = serde_yaml::from_value(
providers
.get(serde_yaml::Value::String("claude-acp".to_string()))
.unwrap()
.clone(),
)
.unwrap();
assert_eq!(claude.model, "current");
assert!(claude.configured);
let lmstudio: ProviderEntry = serde_yaml::from_value(
providers
.get(serde_yaml::Value::String("lmstudio".to_string()))
.unwrap()
.clone(),
)
.unwrap();
// lmstudio was not the active provider, so model should be empty
assert_eq!(lmstudio.model, "");
assert!(lmstudio.configured);
// Old markers removed
assert!(!config.contains_key(serde_yaml::Value::String(
"claude-acp_configured".to_string()
)));
assert!(!config.contains_key(serde_yaml::Value::String("lmstudio_configured".to_string())));
}
#[test]
fn test_migrate_provider_config_idempotent() {
let mut config = Mapping::new();
config.insert(
serde_yaml::Value::String("GOOSE_PROVIDER".to_string()),
serde_yaml::Value::String("openai".to_string()),
);
config.insert(
serde_yaml::Value::String("GOOSE_MODEL".to_string()),
serde_yaml::Value::String("gpt-4o".to_string()),
);
let changed_first = migrate_provider_config(&mut config);
assert!(changed_first);
let changed_second = migrate_provider_config(&mut config);
assert!(!changed_second, "Second migration run should be a no-op");
}
#[test]
fn test_migrate_provider_config_empty_config() {
let mut config = Mapping::new();
let changed = migrate_provider_config(&mut config);
assert!(!changed, "Empty config should not trigger migration");
}
#[test]
fn test_migrate_provider_config_no_model() {
let mut config = Mapping::new();
config.insert(
serde_yaml::Value::String("GOOSE_PROVIDER".to_string()),
serde_yaml::Value::String("anthropic".to_string()),
);
// No GOOSE_MODEL key
let changed = migrate_provider_config(&mut config);
assert!(changed);
let providers = config
.get(serde_yaml::Value::String("providers".to_string()))
.unwrap()
.as_mapping()
.unwrap();
let entry: ProviderEntry = serde_yaml::from_value(
providers
.get(serde_yaml::Value::String("anthropic".to_string()))
.unwrap()
.clone(),
)
.unwrap();
assert_eq!(entry.model, "");
}
#[test]
fn test_cleanup_legacy_keys_when_providers_exists() {
let mut config = Mapping::new();
// Simulate state: providers block exists but stale flat keys remain
let mut providers_map = Mapping::new();
if let Ok(value) = serde_yaml::to_value(&ProviderEntry {
enabled: true,
model: "current".to_string(),
configured: true,
}) {
providers_map.insert(serde_yaml::Value::String("claude-acp".to_string()), value);
}
config.insert(
serde_yaml::Value::String("providers".to_string()),
serde_yaml::Value::Mapping(providers_map),
);
config.insert(
serde_yaml::Value::String("GOOSE_PROVIDER".to_string()),
serde_yaml::Value::String("lmstudio".to_string()),
);
config.insert(
serde_yaml::Value::String("GOOSE_MODEL".to_string()),
serde_yaml::Value::String("some-model".to_string()),
);
config.insert(
serde_yaml::Value::String("claude-acp_configured".to_string()),
serde_yaml::Value::Bool(true),
);
let changed = migrate_provider_config(&mut config);
assert!(changed);
// Legacy keys should be gone
assert!(!config.contains_key(serde_yaml::Value::String("GOOSE_PROVIDER".to_string())));
assert!(!config.contains_key(serde_yaml::Value::String("GOOSE_MODEL".to_string())));
assert!(!config.contains_key(serde_yaml::Value::String(
"claude-acp_configured".to_string()
)));
// Providers block should be untouched
assert!(config.contains_key(serde_yaml::Value::String("providers".to_string())));
// active_provider should be backfilled from legacy GOOSE_PROVIDER
assert_eq!(
config
.get(serde_yaml::Value::String("active_provider".to_string()))
.and_then(|v| v.as_str()),
Some("lmstudio")
);
}
}
+5
View File
@@ -6,6 +6,7 @@ pub mod goose_mode;
mod migrations;
pub mod paths;
pub mod permission;
pub mod providers;
pub mod search_path;
pub mod signup_nanogpt;
pub mod signup_openrouter;
@@ -30,3 +31,7 @@ pub use extensions::DEFAULT_DISPLAY_NAME;
pub use extensions::DEFAULT_EXTENSION;
pub use extensions::DEFAULT_EXTENSION_DESCRIPTION;
pub use extensions::DEFAULT_EXTENSION_TIMEOUT;
pub use providers::{
get_active_model, get_active_provider, get_provider_entry, set_active_provider,
set_provider_entry, ProviderEntry,
};
+259
View File
@@ -0,0 +1,259 @@
use super::base::{Config, ConfigError};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_yaml::Mapping;
use std::env;
use tracing::warn;
const PROVIDERS_CONFIG_KEY: &str = "providers";
const ACTIVE_PROVIDER_KEY: &str = "active_provider";
/// A single provider's persisted configuration within the `providers:` block.
///
/// The `providers` block in config.yaml is the authoritative source for
/// per-provider settings, replacing the old flat-key scheme where switching
/// providers destructively overwrote `GOOSE_PROVIDER` / `GOOSE_MODEL`.
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ProviderEntry {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub model: String,
#[serde(default)]
pub configured: bool,
}
// ---------------------------------------------------------------------------
// Read helpers
// ---------------------------------------------------------------------------
fn parse_providers_map(raw: Mapping) -> IndexMap<String, ProviderEntry> {
let mut map = IndexMap::with_capacity(raw.len());
for (k, v) in raw {
match (k, serde_yaml::from_value::<ProviderEntry>(v)) {
(serde_yaml::Value::String(key), Ok(entry)) => {
map.insert(key, entry);
}
(k, v) => {
warn!(
key = ?k,
value = ?v,
"Skipping malformed provider config entry"
);
}
}
}
map
}
fn get_providers_map(config: &Config) -> IndexMap<String, ProviderEntry> {
let raw: Mapping = config
.get_param(PROVIDERS_CONFIG_KEY)
.unwrap_or_else(|_| Default::default());
parse_providers_map(raw)
}
/// Retrieve the [`ProviderEntry`] for a named provider, if it exists.
pub fn get_provider_entry(config: &Config, name: &str) -> Option<ProviderEntry> {
get_providers_map(config).get(name).cloned()
}
// ---------------------------------------------------------------------------
// Write helpers
// ---------------------------------------------------------------------------
/// Persist a [`ProviderEntry`] under `providers.{name}`.
pub fn set_provider_entry(
config: &Config,
name: &str,
entry: &ProviderEntry,
) -> Result<(), ConfigError> {
let name = name.to_string();
let entry = entry.clone();
config.update_param::<Mapping, _, _>(PROVIDERS_CONFIG_KEY, |raw| {
let mut map = parse_providers_map(raw);
map.insert(name, entry);
map
})
}
// ---------------------------------------------------------------------------
// Active-provider accessors
// ---------------------------------------------------------------------------
/// Return the currently active provider name.
///
/// Resolution order:
/// 1. `GOOSE_PROVIDER` environment variable (uppercase check performed by
/// `get_param`)
/// 2. `active_provider` key in config.yaml
/// 3. Legacy flat `GOOSE_PROVIDER` key in config.yaml (backward compat)
pub fn get_active_provider(config: &Config) -> Option<String> {
// Env var takes precedence (get_param checks env automatically)
if let Ok(val) = env::var("GOOSE_PROVIDER") {
return Some(val);
}
// New structured key
if let Ok(val) = config.get_param::<String>(ACTIVE_PROVIDER_KEY) {
return Some(val);
}
// Legacy flat key fallback
config.get_param::<String>("GOOSE_PROVIDER").ok()
}
/// Return the model for the currently active provider.
///
/// Resolution order:
/// 1. `GOOSE_MODEL` environment variable
/// 2. Model recorded in the active provider's entry (`providers.{name}.model`)
/// 3. Legacy flat `GOOSE_MODEL` key in config.yaml
pub fn get_active_model(config: &Config) -> Option<String> {
// Env var takes precedence
if let Ok(val) = env::var("GOOSE_MODEL") {
return Some(val);
}
// Try provider entry model
if let Some(provider_name) = get_active_provider(config) {
if let Some(entry) = get_provider_entry(config, &provider_name) {
if !entry.model.is_empty() {
return Some(entry.model);
}
}
}
// Legacy flat key fallback
config.get_param::<String>("GOOSE_MODEL").ok()
}
/// Set the active provider and update its entry in the `providers` block.
///
/// This writes:
/// - `active_provider: {name}` at the top level
/// - `providers.{name}` with `configured: true`, `enabled: true`, and the
/// supplied model.
pub fn set_active_provider(config: &Config, name: &str, model: &str) -> Result<(), ConfigError> {
config.set_param(ACTIVE_PROVIDER_KEY, name)?;
let entry = ProviderEntry {
enabled: true,
model: model.to_string(),
configured: true,
};
set_provider_entry(config, name, &entry)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
fn new_test_config() -> Config {
let config_file = NamedTempFile::new().unwrap();
let secrets_file = NamedTempFile::new().unwrap();
Config::new_with_file_secrets(config_file.path(), secrets_file.path()).unwrap()
}
#[test]
fn test_set_and_get_provider_entry() {
let config = new_test_config();
let entry = ProviderEntry {
enabled: true,
model: "gpt-4o".to_string(),
configured: true,
};
set_provider_entry(&config, "openai", &entry).unwrap();
let loaded = get_provider_entry(&config, "openai").unwrap();
assert!(loaded.enabled);
assert_eq!(loaded.model, "gpt-4o");
assert!(loaded.configured);
}
#[test]
fn test_get_provider_entry_missing() {
let config = new_test_config();
assert!(get_provider_entry(&config, "nonexistent").is_none());
}
#[test]
fn test_set_active_provider_writes_structured_keys() {
let config = new_test_config();
set_active_provider(&config, "claude-acp", "current").unwrap();
let active: String = config.get_param(ACTIVE_PROVIDER_KEY).unwrap();
assert_eq!(active, "claude-acp");
let entry = get_provider_entry(&config, "claude-acp").unwrap();
assert!(entry.enabled);
assert!(entry.configured);
assert_eq!(entry.model, "current");
}
#[test]
fn test_get_active_provider_from_new_key() {
let config = new_test_config();
config.set_param(ACTIVE_PROVIDER_KEY, "openai").unwrap();
let result = get_active_provider(&config);
assert_eq!(result, Some("openai".to_string()));
}
#[test]
fn test_get_active_provider_falls_back_to_legacy() {
let config = new_test_config();
config.set_param("GOOSE_PROVIDER", "anthropic").unwrap();
let result = get_active_provider(&config);
assert_eq!(result, Some("anthropic".to_string()));
}
#[test]
fn test_get_active_provider_none_when_empty() {
let config = new_test_config();
let result = get_active_provider(&config);
assert_eq!(result, None);
}
#[test]
fn test_get_active_model_from_provider_entry() {
let config = new_test_config();
set_active_provider(&config, "openai", "gpt-4o").unwrap();
let result = get_active_model(&config);
assert_eq!(result, Some("gpt-4o".to_string()));
}
#[test]
fn test_get_active_model_falls_back_to_legacy() {
let config = new_test_config();
// Only set the legacy key, no providers block
config.set_param("GOOSE_MODEL", "gpt-3.5-turbo").unwrap();
let result = get_active_model(&config);
assert_eq!(result, Some("gpt-3.5-turbo".to_string()));
}
#[test]
fn test_multiple_providers_preserved() {
let config = new_test_config();
// Set up two providers
set_active_provider(&config, "openai", "gpt-4o").unwrap();
set_active_provider(&config, "anthropic", "claude-3-opus").unwrap();
// Both entries should exist
let openai = get_provider_entry(&config, "openai").unwrap();
assert_eq!(openai.model, "gpt-4o");
assert!(openai.configured);
let anthropic = get_provider_entry(&config, "anthropic").unwrap();
assert_eq!(anthropic.model, "claude-3-opus");
assert!(anthropic.configured);
// Active provider should be the last one set
let active = get_active_provider(&config);
assert_eq!(active, Some("anthropic".to_string()));
}
}
@@ -113,6 +113,10 @@ pub async fn complete_nanogpt_auth() -> Result<String> {
pub fn configure_nanogpt(config: &Config, api_key: String) -> Result<()> {
config.set_secret("NANOGPT_API_KEY", &api_key)?;
config.set_goose_provider("nano-gpt")?;
crate::config::set_active_provider(
config,
crate::providers::nanogpt::NANOGPT_PROVIDER_NAME,
crate::providers::nanogpt::NANOGPT_DEFAULT_MODEL,
)?;
Ok(())
}
@@ -165,7 +165,10 @@ use crate::config::Config;
pub fn configure_openrouter(config: &Config, api_key: String) -> Result<()> {
config.set_secret("OPENROUTER_API_KEY", &api_key)?;
config.set_goose_provider("openrouter")?;
config.set_goose_model(OPENROUTER_DEFAULT_MODEL)?;
crate::config::set_active_provider(
config,
crate::providers::openrouter::OPENROUTER_PROVIDER_NAME,
OPENROUTER_DEFAULT_MODEL,
)?;
Ok(())
}
@@ -162,7 +162,10 @@ use crate::config::Config;
pub fn configure_tetrate(config: &Config, api_key: String) -> Result<()> {
config.set_secret("TETRATE_API_KEY", &api_key)?;
config.set_goose_provider("tetrate")?;
config.set_goose_model(TETRATE_DEFAULT_MODEL)?;
crate::config::set_active_provider(
config,
crate::providers::tetrate::TETRATE_PROVIDER_NAME,
TETRATE_DEFAULT_MODEL,
)?;
Ok(())
}
+5 -4
View File
@@ -141,10 +141,11 @@ async fn save_and_set(
provider: Arc<dyn Provider>,
) -> anyhow::Result<()> {
let config = Config::global();
config.set_goose_provider(provider.get_name()).ok();
config
.set_goose_model(&provider.get_model_config().model_name)
.ok();
crate::config::set_active_provider(
config,
provider.get_name(),
&provider.get_model_config().model_name,
)?;
agent.update_provider(provider, session_id).await
}
+4 -4
View File
@@ -355,10 +355,10 @@ async fn send_error_event(
}
let config = Config::global();
if let Ok(provider) = config.get_param::<String>("GOOSE_PROVIDER") {
if let Ok(provider) = config.get_goose_provider() {
insert(&mut props, "provider", provider);
}
if let Ok(model) = config.get_param::<String>("GOOSE_MODEL") {
if let Ok(model) = config.get_goose_model() {
insert(&mut props, "model", model);
}
@@ -407,10 +407,10 @@ async fn send_session_event(installation: &InstallationData) -> Result<(), Strin
insert(&mut props, "days_since_install", days_since_install);
let config = Config::global();
if let Ok(provider) = config.get_param::<String>("GOOSE_PROVIDER") {
if let Ok(provider) = config.get_goose_provider() {
insert(&mut props, "provider", provider);
}
if let Ok(model) = config.get_param::<String>("GOOSE_MODEL") {
if let Ok(model) = config.get_goose_model() {
insert(&mut props, "model", model);
}
+1 -1
View File
@@ -36,7 +36,7 @@ impl ProviderDef for AmpAcpProvider {
"Install the Amp CLI: `curl -fsSL https://ampcode.com/install.sh | bash`",
"Install the ACP adapter: `npm install -g amp-acp`",
"Ensure your Amp CLI is authenticated (run `amp` to verify)",
"Set in your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: amp-acp\n GOOSE_MODEL: current",
"Add to your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: amp-acp\n GOOSE_MODEL: current\n amp-acp_configured: true",
"Restart goose for changes to take effect",
])
.with_model_selection_hint("Use the Amp CLI to configure models")
+1 -1
View File
@@ -35,7 +35,7 @@ impl ProviderDef for ClaudeAcpProvider {
.with_setup_steps(vec![
"Install the ACP adapter: `npm install -g @agentclientprotocol/claude-agent-acp`",
"Ensure your Claude CLI is authenticated (run `claude` to verify)",
"Set in your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: claude-acp\n GOOSE_MODEL: current",
"Add to your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: claude-acp\n GOOSE_MODEL: current\n claude-acp_configured: true",
"Restart goose for changes to take effect",
])
}
+1 -1
View File
@@ -34,7 +34,7 @@ impl ProviderDef for CodexAcpProvider {
.with_setup_steps(vec![
"Install the ACP adapter: `npm install -g @zed-industries/codex-acp`",
"Run `codex` once to authenticate with your OpenAI account",
"Set in your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: codex-acp\n GOOSE_MODEL: current",
"Add to your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: codex-acp\n GOOSE_MODEL: current\n codex-acp_configured: true",
"Restart goose for changes to take effect",
])
}
+1 -1
View File
@@ -38,7 +38,7 @@ impl ProviderDef for CopilotAcpProvider {
.with_setup_steps(vec![
"Install the Copilot CLI: `npm install -g @github/copilot`",
"Run `copilot login` to authenticate with your GitHub account",
"Set in your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: copilot-acp\n GOOSE_MODEL: current",
"Add to your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: copilot-acp\n GOOSE_MODEL: current\n copilot-acp_configured: true",
"Restart goose for changes to take effect",
])
}
+1 -1
View File
@@ -12,7 +12,7 @@ use async_trait::async_trait;
use futures::future::BoxFuture;
use rmcp::model::Tool;
const NANOGPT_PROVIDER_NAME: &str = "nano-gpt";
pub const NANOGPT_PROVIDER_NAME: &str = "nano-gpt";
pub const NANOGPT_API_HOST: &str = "https://nano-gpt.com/api/v1";
pub const NANOGPT_SUBSCRIPTION_HOST: &str = "https://nano-gpt.com/api/subscription/v1";
pub const NANOGPT_DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4.6";
+1 -1
View File
@@ -15,7 +15,7 @@ use crate::providers::formats::openai::create_request;
use crate::providers::formats::openrouter as openrouter_format;
use rmcp::model::Tool;
const OPENROUTER_PROVIDER_NAME: &str = "openrouter";
pub const OPENROUTER_PROVIDER_NAME: &str = "openrouter";
pub const OPENROUTER_DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4";
pub const OPENROUTER_DEFAULT_FAST_MODEL: &str = "google/gemini-2.5-flash";
pub const OPENROUTER_MODEL_PREFIX_ANTHROPIC: &str = "anthropic";
+1 -1
View File
@@ -35,7 +35,7 @@ impl ProviderDef for PiAcpProvider {
.with_setup_steps(vec![
"Install the Pi CLI and the pi-acp adapter",
"Ensure your Pi CLI is authenticated (run `pi` to verify)",
"Set in your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: pi-acp\n GOOSE_MODEL: current",
"Add to your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: pi-acp\n GOOSE_MODEL: current\n pi-acp_configured: true",
"Restart goose for changes to take effect",
])
.with_model_selection_hint("Use the Pi CLI to configure models")
+1 -1
View File
@@ -18,7 +18,7 @@ use crate::providers::formats::openai::create_request;
use rmcp::model::Tool;
use serde_json::Value;
const TETRATE_PROVIDER_NAME: &str = "tetrate";
pub const TETRATE_PROVIDER_NAME: &str = "tetrate";
pub const TETRATE_DOC_URL: &str = "https://router.tetrate.ai";
pub const TETRATE_BILLING_URL: &str = "https://router.tetrate.ai/billing";
+4
View File
@@ -6897,6 +6897,10 @@
},
"provider_type": {
"$ref": "#/components/schemas/ProviderType"
},
"saved_model": {
"type": "string",
"nullable": true
}
}
},
+1
View File
@@ -953,6 +953,7 @@ export type ProviderDetails = {
metadata: ProviderMetadata;
name: string;
provider_type: ProviderType;
saved_model?: string | null;
};
export type ProviderEngine = 'openai' | 'ollama' | 'anthropic';
@@ -594,6 +594,13 @@ export const SwitchModelModal = ({
// Don't auto-select if user explicitly cleared the model
if (!provider || loadingModels || model || isCustomModel || userClearedModel) return;
// Use saved model from provider config if available
const providerInfo = activeProvidersList.find((p) => p.name === provider);
if (providerInfo?.saved_model) {
setModel(providerInfo.saved_model);
return;
}
const providerModels = modelOptions
.filter((group) => group.options[0]?.provider === provider)
.flatMap((group) => group.options);
@@ -604,7 +611,7 @@ export const SwitchModelModal = ({
setModel(preferredModel);
}
}
}, [provider, modelOptions, loadingModels, model, isCustomModel, userClearedModel]);
}, [provider, modelOptions, loadingModels, model, isCustomModel, userClearedModel, activeProvidersList]);
// Handle model selection change
const handleModelChange = (newValue: unknown) => {