diff --git a/plugins/summarize/plugin.toml b/plugins/summarize/plugin.toml index 7f633cd92..d9db261d1 100644 --- a/plugins/summarize/plugin.toml +++ b/plugins/summarize/plugin.toml @@ -19,6 +19,7 @@ default = "gemini" [options.model] type = "string" +"x-reset-by" = "provider" title = "Model" description = "The model name sent to the provider. Unset uses the provider's default." @@ -35,11 +36,13 @@ default = 20 [options.api_key] type = "string" +"x-reset-by" = "provider" title = "API key" description = "Sent to the selected provider. The provider's variable in the environment is used when this is unset. Optional for `openai` with a custom `endpoint`." [options.endpoint] type = "string" +"x-reset-by" = "provider" title = "Endpoint URL" description = "Override the provider's base URL, for proxies, OpenAI-compatible servers (for example `https://openrouter.ai/api/v1` or `http://localhost:11434/v1`) and tests. Empty uses the provider's default." @@ -68,7 +71,7 @@ default = 3 [options.system_prompt] type = "string" title = "System prompt" -description = "The system instruction sent with every request; diffr builds each request's message from the file's numbered lines and the folds to summarize. Unset uses the default: https://github.com/devdotfast/diffr/blob/main/plugins/summarize/plugin.toml#L72-L78" +description = "The system instruction sent with every request; diffr builds each request's message from the file's numbered lines and the folds to summarize. Unset uses the default: https://github.com/devdotfast/diffr/blob/main/plugins/summarize/plugin.toml#L75-L81" default = """\ For each listed fold, rewrite that function body as short \ pseudocode. Keep the names. No prose, no comments, no code fences. Use as few lines as \ @@ -94,6 +97,7 @@ default = 20 # screens do not edit an object option, so this is a file-only override. [options.provider_details] type = "object" +"x-reset-by" = "provider" title = "Provider details" description = "The provider's default endpoint, the environment variables that hold its key, and whether a custom endpoint may go without a key." required = ["endpoint", "key_variables", "keyless_custom_endpoint"] diff --git a/src/config/prune.rs b/src/config/prune.rs index d69cd55c6..9c790f44e 100644 --- a/src/config/prune.rs +++ b/src/config/prune.rs @@ -154,7 +154,7 @@ fn get<'a>(table: &'a dyn TableLike, path: &[&str]) -> Option<&'a Item> { } } -fn remove(table: &mut dyn TableLike, path: &[&str]) { +pub(crate) fn remove(table: &mut dyn TableLike, path: &[&str]) { match path { [key] => { table.remove(key); diff --git a/src/config/store.rs b/src/config/store.rs index f7d377c8d..5716d59e1 100644 --- a/src/config/store.rs +++ b/src/config/store.rs @@ -44,6 +44,7 @@ pub(crate) fn set(path: &Path, key: &str, value: &str) -> Result<(), ConfigError let mut document: toml_edit::DocumentMut = existing .parse() .map_err(|error| ConfigError(format!("{}: {error}", path.display())))?; + reset(&mut document, key, &typed, &existing, directory)?; assign(&mut document, key, &typed)?; prune(&mut document, directory)?; write(path, document) @@ -57,6 +58,34 @@ fn read(path: &Path) -> Result { } } +/// When `key` takes a new value, clear the options of its plugin that +/// belong to it: a provider's key, endpoint and model go with the provider. +fn reset( + document: &mut toml_edit::DocumentMut, + key: &str, + value: &toml::Value, + existing: &str, + directory: &Path, +) -> Result<(), ConfigError> { + let ["plugins", namespace, name, field] = key.split('.').collect::>()[..] else { + return Ok(()); + }; + let config = Config::from_toml_in(existing, directory)?; + let Some(entry) = config.plugins.entries.get(&format!("{namespace}.{name}")) else { + return Ok(()); + }; + if entry.options.get(field) == serde_json::to_value(value).ok().as_ref() { + return Ok(()); + } + for option in entry.folder().manifest.reset_by(field) { + super::prune::remove( + document.as_table_mut(), + &["plugins", namespace, name, option], + ); + } + Ok(()) +} + /// Validate the document, then keep only what differs from the defaults, /// and the file's `version`. fn prune(document: &mut toml_edit::DocumentMut, directory: &Path) -> Result<(), ConfigError> { @@ -609,6 +638,35 @@ mod sparse_tests { ); } + #[test] + fn a_new_provider_clears_the_old_ones_settings_but_keeps_the_prompt() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let summarize = |key: &str, value: &str| { + set(&path, &format!("plugins.bundled.summarize.{key}"), value).unwrap() + }; + summarize("provider", "anthropic"); + summarize("api_key", "anthropic-key"); + summarize("endpoint", "https://proxy.example/anthropic"); + summarize("model", "claude-sonnet-5-5"); + summarize("system_prompt", "Be terse."); + summarize("min_lines", "7"); + // The same provider again changes nothing. + summarize("provider", "anthropic"); + let raw = read_toml(&path); + assert_eq!( + raw["plugins"]["bundled"]["summarize"]["api_key"].as_str(), + Some("anthropic-key") + ); + summarize("provider", "gemini"); + let raw = read_toml(&path); + let entry = raw["plugins"]["bundled"]["summarize"].as_table().unwrap(); + let mut keys: Vec<&str> = entry.keys().map(String::as_str).collect(); + keys.sort_unstable(); + // Gemini is the default provider, so it is pruned too. + assert_eq!(keys, ["min_lines", "system_prompt"], "{raw}"); + } + #[test] fn an_explicit_plugin_list_still_pins_membership() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/plugin/config.rs b/src/plugin/config.rs index c184a55c6..c4203a3c3 100644 --- a/src/plugin/config.rs +++ b/src/plugin/config.rs @@ -29,6 +29,8 @@ pub(crate) const MANIFEST_FILE: &str = "plugin.toml"; /// An option's default that depends on another option's value: /// `{ key = "