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
44 changes: 40 additions & 4 deletions plugins/summarize/plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 \
Expand All @@ -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
Binary file modified plugins/summarize/plugin.wasm
Binary file not shown.
9 changes: 5 additions & 4 deletions plugins/summarize/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -305,7 +306,7 @@ fn resolve_key(config: &Options, custom_endpoint: bool) -> anyhow::Result<Option
if let Some(key) = config.api_key.clone().filter(set) {
return Ok(Some(key));
}
let variables = config.provider.key_variables();
let variables = &config.provider_details.key_variables;
for variable in variables {
if let Some(key) = std::env::var_os(variable) {
let key = key
Expand All @@ -316,7 +317,7 @@ fn resolve_key(config: &Options, custom_endpoint: bool) -> anyhow::Result<Option
}
}
}
if config.provider.key_optional(custom_endpoint) {
if config.provider_details.keyless_custom_endpoint && custom_endpoint {
return Ok(None);
}
anyhow::bail!(
Expand All @@ -336,7 +337,7 @@ impl Plugin for Summarize {
let api_key = resolve_key(&options, endpoint.is_some())?;
Ok(Self {
api_key,
endpoint: endpoint.unwrap_or_else(|| options.provider.default_endpoint().to_owned()),
endpoint: endpoint.unwrap_or_else(|| options.provider_details.endpoint.clone()),
options,
})
}
Expand Down
36 changes: 13 additions & 23 deletions plugins/summarize/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::{json, Value};

/// What `plugin.toml` records per provider, filled in by the host.
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Details {
/// The base URL used when `endpoint` is unset.
pub endpoint: String,
/// The environment variables read, in order, when `api_key` is unset.
pub key_variables: Vec<String>,
/// 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 {
Expand All @@ -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 {
Expand Down
1 change: 0 additions & 1 deletion src/config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 17 additions & 15 deletions src/config/prune.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<toml::Value>) {
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;
}
Expand Down Expand Up @@ -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<Vec<String>> {
/// 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<Vec<String>> {
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)
})
}

Expand Down
2 changes: 2 additions & 0 deletions src/config/prune/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions src/config/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading