diff --git a/plugins/summarize/plugin.toml b/plugins/summarize/plugin.toml
index 7ff250fdc..7f633cd92 100644
--- a/plugins/summarize/plugin.toml
+++ b/plugins/summarize/plugin.toml
@@ -12,15 +12,19 @@ default = false
[options.provider]
enum = ["gemini", "openai", "anthropic"]
+"x-enum-titles" = ["Gemini", "OpenAI", "Anthropic"]
title = "Provider"
-description = "Which model API to call. `openai` also reaches OpenRouter and any other OpenAI-compatible server through `endpoint`. Set `model` to one of the provider's models when you change this."
+description = "Which model API to call. `openai` also reaches OpenRouter and any other OpenAI-compatible server through `endpoint`."
default = "gemini"
[options.model]
type = "string"
title = "Model"
-description = "The model name sent to the provider."
-default = "gemini-3.8-flash"
+description = "The model name sent to the provider. Unset uses the provider's default."
+
+[options.model."x-default-by"]
+key = "provider"
+values = { gemini = "gemini-3.8-flash", openai = "gpt-6-luna", anthropic = "claude-haiku-4-5" }
[options.min_lines]
type = "integer"
@@ -64,7 +68,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#L68-L74"
+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"
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 \
@@ -85,3 +89,35 @@ minimum = 0
title = "Shortest test body to summarize (lines)"
description = "Test bodies shorter than this keep their ordinary fold labels."
default = 20
+
+# Per-provider facts the summarizer and settings screens share. Settings
+# screens do not edit an object option, so this is a file-only override.
+[options.provider_details]
+type = "object"
+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"]
+additionalProperties = false
+
+[options.provider_details.properties]
+endpoint = { type = "string" }
+key_variables = { type = "array", items = { type = "string" } }
+keyless_custom_endpoint = { type = "boolean" }
+
+[options.provider_details."x-default-by"]
+key = "provider"
+
+[options.provider_details."x-default-by".values.gemini]
+endpoint = "https://generativelanguage.googleapis.com"
+key_variables = ["GEMINI_API_KEY", "GOOGLE_API_KEY"]
+keyless_custom_endpoint = false
+
+[options.provider_details."x-default-by".values.openai]
+endpoint = "https://api.openai.com/v1"
+key_variables = ["OPENAI_API_KEY"]
+keyless_custom_endpoint = true
+
+[options.provider_details."x-default-by".values.anthropic]
+endpoint = "https://api.anthropic.com"
+key_variables = ["ANTHROPIC_API_KEY"]
+keyless_custom_endpoint = false
diff --git a/plugins/summarize/plugin.wasm b/plugins/summarize/plugin.wasm
index f5c7b3f5e..be2243b62 100644
Binary files a/plugins/summarize/plugin.wasm and b/plugins/summarize/plugin.wasm differ
diff --git a/plugins/summarize/src/lib.rs b/plugins/summarize/src/lib.rs
index f9746717e..f4a64eae0 100644
--- a/plugins/summarize/src/lib.rs
+++ b/plugins/summarize/src/lib.rs
@@ -15,7 +15,7 @@ use std::collections::BTreeMap;
use std::time::Duration;
mod http;
mod provider;
-pub use provider::Provider;
+pub use provider::{Details, Provider};
/// The plugin's name, and the tags its queries set: a function body, and a
/// test body, which can be summarized independently of whether it is new.
@@ -28,6 +28,7 @@ const TEST: &str = "summarize:test";
#[serde(deny_unknown_fields)]
pub struct Options {
pub provider: Provider,
+ pub provider_details: Details,
pub model: String,
pub min_lines: usize,
pub tests: bool,
@@ -305,7 +306,7 @@ fn resolve_key(config: &Options, custom_endpoint: bool) -> anyhow::Result anyhow::Result ,
+ /// Whether a custom endpoint may go without a key, as OpenAI-compatible
+ /// servers such as Ollama often do.
+ pub keyless_custom_endpoint: bool,
+}
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
@@ -13,29 +26,6 @@ pub enum Provider {
}
impl Provider {
- pub fn default_endpoint(self) -> &'static str {
- match self {
- Self::Gemini => "https://generativelanguage.googleapis.com",
- Self::OpenAi => "https://api.openai.com/v1",
- Self::Anthropic => "https://api.anthropic.com",
- }
- }
-
- /// The environment variables read, in order, when `api_key` is unset.
- pub fn key_variables(self) -> &'static [&'static str] {
- match self {
- Self::Gemini => &["GEMINI_API_KEY", "GOOGLE_API_KEY"],
- Self::OpenAi => &["OPENAI_API_KEY"],
- Self::Anthropic => &["ANTHROPIC_API_KEY"],
- }
- }
-
- /// OpenAI-compatible servers at a custom endpoint, such as Ollama,
- /// often take no key.
- pub fn key_optional(self, custom_endpoint: bool) -> bool {
- self == Self::OpenAi && custom_endpoint
- }
-
pub fn url(self, endpoint: &str, model: &str) -> String {
let endpoint = endpoint.trim_end_matches('/');
match self {
diff --git a/src/config/default.toml b/src/config/default.toml
index 8eadb49ac..ee8a0df65 100644
--- a/src/config/default.toml
+++ b/src/config/default.toml
@@ -39,7 +39,6 @@ min_lines = 5
[plugins.bundled.summarize]
enabled = false
provider = "gemini"
-model = "gemini-3.8-flash"
min_lines = 20
tests = true
test_min_lines = 20
diff --git a/src/config/prune.rs b/src/config/prune.rs
index ee9884f7a..d69cd55c6 100644
--- a/src/config/prune.rs
+++ b/src/config/prune.rs
@@ -38,15 +38,15 @@ pub(crate) fn forget_legacy(source: &str) -> Cow<'_, str> {
}
}
-/// Remove each value, then each table that holds no values, whose removal
-/// leaves `resolve` of the document unchanged. `version` stays, and so does
+/// Remove each value, then each table, whose removal leaves `resolve` of
+/// the document unchanged: an object option is only valid whole. `version` stays, and so does
/// anything with a comment on it: a note marks intent.
pub(crate) fn prune(document: &mut DocumentMut, resolve: impl Fn(&str) -> Option) {
let Some(target) = resolve(&document.to_string()) else {
return;
};
- for hollow_tables in [false, true] {
- for path in removable(document.as_table(), hollow_tables) {
+ for tables in [false, true] {
+ for path in removable(document.as_table(), tables) {
if path == ["version"] {
continue;
}
@@ -75,34 +75,36 @@ fn hide_headers(table: &mut toml_edit::Table) {
}
}
-/// The dotted paths of the table's uncommented values or, with
-/// `hollow_tables`, of its outermost tables that hold no values, deepest
-/// first.
-fn removable(table: &dyn TableLike, hollow_tables: bool) -> Vec> {
+/// The dotted paths of the table's uncommented values or, with `tables`,
+/// of its uncommented tables, deepest first. A table holding a comment at
+/// any depth stays.
+fn removable(table: &dyn TableLike, tables: bool) -> Vec> {
let mut paths = Vec::new();
for (key, item) in table.iter() {
if has_comment(table, key, item) {
continue;
}
match item.as_table_like() {
- Some(child) if hollow_tables && hollow(child) => paths.push(vec![key.to_owned()]),
Some(child) => {
- for mut path in removable(child, hollow_tables) {
+ for mut path in removable(child, tables) {
path.insert(0, key.to_owned());
paths.push(path);
}
+ if tables && !annotated(child) {
+ paths.push(vec![key.to_owned()]);
+ }
}
- None if !hollow_tables => paths.push(vec![key.to_owned()]),
+ None if !tables => paths.push(vec![key.to_owned()]),
None => {}
}
}
paths
}
-/// A table with no values and no comments, at any depth.
-fn hollow(table: &dyn TableLike) -> bool {
- table.iter().all(|(key, item)| {
- !has_comment(table, key, item) && item.as_table_like().is_some_and(hollow)
+/// Whether anything in the table, at any depth, has a comment.
+fn annotated(table: &dyn TableLike) -> bool {
+ table.iter().any(|(key, item)| {
+ has_comment(table, key, item) || item.as_table_like().is_some_and(annotated)
})
}
diff --git a/src/config/prune/tests.rs b/src/config/prune/tests.rs
index ac0069c55..0b5cea2e6 100644
--- a/src/config/prune/tests.rs
+++ b/src/config/prune/tests.rs
@@ -17,6 +17,8 @@ fn settings() -> Vec<(String, Value)> {
None => node,
};
match node.get("properties").and_then(Value::as_object) {
+ // An object option is set whole, from the file, never by field.
+ Some(_) if node.get("x-settings") == Some(&Value::Bool(false)) => {}
Some(properties) => {
for (name, child) in properties {
let key = match key {
diff --git a/src/config/store.rs b/src/config/store.rs
index b7db4ad40..f7d377c8d 100644
--- a/src/config/store.rs
+++ b/src/config/store.rs
@@ -588,6 +588,27 @@ mod sparse_tests {
assert!(!text.contains("plugins"), "{text}");
}
+ #[test]
+ fn a_model_equal_to_its_providers_default_is_dropped() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("config.toml");
+ set(&path, "plugins.bundled.summarize.provider", "openai").unwrap();
+ set(&path, "plugins.bundled.summarize.model", "gpt-6-luna").unwrap();
+ let raw = read_toml(&path);
+ assert!(
+ raw["plugins"]["bundled"]["summarize"]
+ .get("model")
+ .is_none(),
+ "{raw}"
+ );
+ set(&path, "plugins.bundled.summarize.model", "gemini-3.8-flash").unwrap();
+ let raw = read_toml(&path);
+ assert_eq!(
+ raw["plugins"]["bundled"]["summarize"]["model"].as_str(),
+ Some("gemini-3.8-flash")
+ );
+ }
+
#[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 996fb15df..c184a55c6 100644
--- a/src/plugin/config.rs
+++ b/src/plugin/config.rs
@@ -26,6 +26,9 @@ pub(crate) const RESERVED: [&str; 3] = [ENABLED, PATH, "instances"];
/// A plugin folder's description, and its component when it has one.
pub(crate) const MANIFEST_FILE: &str = "plugin.toml";
+/// An option's default that depends on another option's value:
+/// `{ key = "", values = { = , ... } }`.
+const DEFAULT_BY: &str = "x-default-by";
pub(crate) const COMPONENT_FILE: &str = "plugin.wasm";
/// A plugin's `plugin.toml`.
@@ -105,7 +108,70 @@ impl Manifest {
));
}
self.validate(&self.defaults())
- .map_err(|error| format!("{}: the defaults: {error}", self.name))
+ .map_err(|error| format!("{}: the defaults: {error}", self.name))?;
+ self.check_defaults_by()
+ }
+
+ /// Every `x-default-by` names an option with choices, gives a default
+ /// for each choice, and every choice's defaults satisfy the schema.
+ fn check_defaults_by(&self) -> Result<(), String> {
+ for (name, option) in &self.options {
+ let Some(by) = option.get(DEFAULT_BY) else {
+ continue;
+ };
+ let invalid =
+ |problem: &str| format!("{}: option {name:?}: {DEFAULT_BY} {problem}", self.name);
+ let key = by
+ .get("key")
+ .and_then(Value::as_str)
+ .ok_or_else(|| invalid("needs a key"))?;
+ let mut chain = vec![name.as_str()];
+ let mut next = key;
+ while let Some(by) = self
+ .options
+ .get(next)
+ .and_then(|option| option.get(DEFAULT_BY))
+ {
+ if chain.contains(&next) {
+ return Err(invalid(&format!("forms a cycle through {chain:?}")));
+ }
+ chain.push(next);
+ next = by.get("key").and_then(Value::as_str).unwrap_or_default();
+ }
+ let values = by
+ .get("values")
+ .and_then(Value::as_object)
+ .ok_or_else(|| invalid("needs values"))?;
+ let choices: Vec<&str> = self
+ .options
+ .get(key)
+ .and_then(|option| option.get("enum"))
+ .and_then(Value::as_array)
+ .ok_or_else(|| invalid(&format!("key {key:?} is not an option with choices")))?
+ .iter()
+ .filter_map(Value::as_str)
+ .collect();
+ let mut given: Vec<&str> = values.keys().map(String::as_str).collect();
+ let mut expected = choices.clone();
+ given.sort_unstable();
+ expected.sort_unstable();
+ if given != expected {
+ return Err(invalid(&format!(
+ "must give a default for each of {choices:?}"
+ )));
+ }
+ for choice in choices {
+ let mut options = Map::from_iter([(key.to_owned(), Value::from(choice))]);
+ self.fill_defaults(&mut options);
+ self.validate(&options).map_err(|error| {
+ format!(
+ "{}: the defaults for {key} = {choice:?}: {error}",
+ self.name
+ )
+ })?;
+ }
+ }
+ Ok(())
}
/// The JSON Schema of the plugin's options as an object: unknown keys
@@ -157,6 +223,41 @@ impl Manifest {
.collect()
}
+ /// Fill every option `options` leaves unset: from its `default`, or from
+ /// its `x-default-by` for the value its key has.
+ pub(crate) fn fill_defaults(&self, options: &mut Map) {
+ for (key, default) in self.defaults() {
+ options.entry(key).or_insert(default);
+ }
+ // A default may follow one that follows another, declared in any
+ // order: fill until a round fills nothing.
+ loop {
+ let mut filled = false;
+ for (name, option) in &self.options {
+ let Some(by) = option
+ .get(DEFAULT_BY)
+ .filter(|_| !options.contains_key(name))
+ else {
+ continue;
+ };
+ let default = by
+ .get("key")
+ .and_then(Value::as_str)
+ .and_then(|key| options.get(key))
+ .and_then(Value::as_str)
+ .and_then(|choice| by["values"].get(choice))
+ .cloned();
+ if let Some(default) = default {
+ options.insert(name.clone(), default);
+ filled = true;
+ }
+ }
+ if !filled {
+ break;
+ }
+ }
+ }
+
/// The entry this plugin adds to `diffr config schema` under `plugins`.
/// Each option keeps its own schema, with the plugin's title as its
/// `x-group` unless it sets one. An option whose type is an array or an
@@ -449,9 +550,7 @@ impl PluginsConfig {
manifest
.validate(&entry.options)
.map_err(|error| ConfigError(format!("plugins.{reference}: {error}")))?;
- for (key, default) in manifest.defaults() {
- entry.options.entry(key).or_insert(default);
- }
+ manifest.fill_defaults(&mut entry.options);
entry.folder = Some(folder);
}
let mut identities = BTreeSet::new();
@@ -566,6 +665,69 @@ mod tests {
);
}
+ #[test]
+ fn defaults_can_follow_another_option() {
+ let summarize = |toml: &str| {
+ Config::from_toml(toml).unwrap().plugins.entries["bundled.summarize"]
+ .options
+ .clone()
+ };
+ let openai = summarize("[plugins.bundled.summarize]\nprovider = 'openai'\n");
+ assert_eq!(openai["model"], "gpt-6-luna");
+ assert_eq!(
+ openai["provider_details"]["key_variables"],
+ serde_json::json!(["OPENAI_API_KEY"])
+ );
+ assert_eq!(summarize("")["model"], "gemini-3.8-flash");
+ let pinned = summarize("[plugins.bundled.summarize]\nprovider = 'openai'\nmodel = 'o9'\n");
+ assert_eq!(pinned["model"], "o9");
+ }
+
+ #[test]
+ fn a_default_can_follow_a_default_that_follows_another() {
+ // Declared so that `size` comes before the `kind` it follows, and
+ // `kind` before the `mode` it follows.
+ let manifest = super::Manifest::parse(
+ "name = 'p'\ntitle = 'P'\n\
+ [options.size]\ntitle = 'Size'\ntype = 'integer'\n\
+ [options.size.\"x-default-by\"]\nkey = 'kind'\nvalues = { small = 1, large = 2 }\n\
+ [options.kind]\ntitle = 'Kind'\nenum = ['small', 'large']\n\
+ [options.kind.\"x-default-by\"]\nkey = 'mode'\nvalues = { a = 'small', b = 'large' }\n\
+ [options.mode]\ntitle = 'Mode'\nenum = ['a', 'b']\ndefault = 'a'\n",
+ )
+ .unwrap();
+ let mut options = serde_json::Map::new();
+ manifest.fill_defaults(&mut options);
+ assert_eq!(options["kind"], "small");
+ assert_eq!(options["size"], 1);
+ let mut options = serde_json::Map::from_iter([("mode".to_owned(), "b".into())]);
+ manifest.fill_defaults(&mut options);
+ assert_eq!(options["size"], 2);
+ let cycle = super::Manifest::parse(
+ "name = 'p'\ntitle = 'P'\n\
+ [options.x]\ntitle = 'X'\nenum = ['a', 'b']\n\
+ [options.x.\"x-default-by\"]\nkey = 'y'\nvalues = { a = 'a', b = 'b' }\n\
+ [options.y]\ntitle = 'Y'\nenum = ['a', 'b']\n\
+ [options.y.\"x-default-by\"]\nkey = 'x'\nvalues = { a = 'a', b = 'b' }\n",
+ )
+ .unwrap_err();
+ assert!(cycle.contains("cycle"), "{cycle}");
+ }
+
+ #[test]
+ fn a_default_by_must_cover_every_choice_and_fit_the_option() {
+ let manifest = |values: &str| {
+ super::Manifest::parse(&format!(
+ "name = 'p'\ntitle = 'P'\n[options.kind]\ntitle = 'Kind'\nenum = ['a', 'b']\ndefault = 'a'\n[options.size]\ntitle = 'Size'\ntype = 'integer'\n[options.size.\"x-default-by\"]\nkey = 'kind'\nvalues = {{ {values} }}\n"
+ ))
+ };
+ assert!(manifest("a = 1, b = 2").is_ok());
+ let missing = manifest("a = 1").unwrap_err();
+ assert!(missing.contains("\"b\""), "{missing}");
+ let mistyped = manifest("a = 1, b = 'big'").unwrap_err();
+ assert!(mistyped.contains("kind = \"b\""), "{mistyped}");
+ }
+
#[test]
fn the_embedded_defaults_agree_with_each_plugin_toml() {
for (name, entry) in super::default_tables().bundled {
diff --git a/src/plugin/tests/mod.rs b/src/plugin/tests/mod.rs
index 1bc6f5500..437e3aad3 100644
--- a/src/plugin/tests/mod.rs
+++ b/src/plugin/tests/mod.rs
@@ -137,13 +137,12 @@ pub(crate) fn rhs(sides: &tree::Pairing) -> &tree::Source {
/// A pipeline of the bundled plugin `name` alone, made with its defaults and
/// `overrides`.
pub(crate) fn bundled(name: &str, overrides: serde_json::Value) -> Pipeline {
- let mut options = builtin::manifest(name)
- .expect("a bundled plugin")
- .defaults();
- let serde_json::Value::Object(overrides) = overrides else {
+ let serde_json::Value::Object(mut options) = overrides else {
panic!("overrides are an object");
};
- options.extend(overrides);
+ builtin::manifest(name)
+ .expect("a bundled plugin")
+ .fill_defaults(&mut options);
let mut pipeline = Pipeline::default();
pipeline
.push(
diff --git a/src/plugin/tests/summarize.rs b/src/plugin/tests/summarize.rs
index 25e9fa2b3..c2f632e40 100644
--- a/src/plugin/tests/summarize.rs
+++ b/src/plugin/tests/summarize.rs
@@ -372,10 +372,11 @@ fn the_system_prompt_is_the_configured_one() {
let (_, mut sides) = project("a.py", "", LARGE);
let (system, _) = request(json!({}), &mut sides);
- let default: Options = serde_json::from_value(serde_json::Value::Object(
- builtin::manifest("summarize").unwrap().defaults(),
- ))
- .unwrap();
+ let mut defaults = serde_json::Map::new();
+ builtin::manifest("summarize")
+ .unwrap()
+ .fill_defaults(&mut defaults);
+ let default: Options = serde_json::from_value(serde_json::Value::Object(defaults)).unwrap();
assert_eq!(system, default.system_prompt.as_str());
assert!(default.system_prompt.starts_with(
"For each listed fold, rewrite that function body as short pseudocode. Keep the names."
@@ -451,13 +452,14 @@ fn external_component_summarizes_over_http() {
),
)
.unwrap();
- let mut options = builtin::manifest("summarize").unwrap().defaults();
- options.extend(
+ let mut options =
json!({"api_key": "test-key", "endpoint": endpoint, "min_lines": 3, "retries": 1})
.as_object()
.unwrap()
- .clone(),
- );
+ .clone();
+ builtin::manifest("summarize")
+ .unwrap()
+ .fill_defaults(&mut options);
let mut pipeline = Pipeline::default();
pipeline
.push(
@@ -757,3 +759,22 @@ fn an_openai_compatible_server_needs_no_key() {
.iter()
.any(|header| header.starts_with("authorization")));
}
+
+#[test]
+fn an_unset_model_is_the_providers_default() {
+ let (file, mut sides) = project("a.py", "", LARGE);
+ let id = select(&trees(&sides), 3, None)[0].0;
+ let text = json!({"summaries": [{"id": id, "summary": "", "pseudocode": "call a, b, c"}]});
+ let response = json!({"content": [{"type": "text", "text": text.to_string()}]});
+ let (endpoint, server) = serve_requests(vec![(200, response.to_string())]);
+ summarizer_with(json!({
+ "provider": "anthropic",
+ "api_key": "test-key",
+ "endpoint": endpoint,
+ "min_lines": 3,
+ }))
+ .run(&file, &mut sides)
+ .unwrap();
+ let body: serde_json::Value = serde_json::from_str(&server.join().unwrap()[0].body).unwrap();
+ assert_eq!(body["model"], "claude-haiku-4-5");
+}