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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,12 @@ We hope in the future that we can find some way to upstream some / all of these

## Known Limitations

1. Semantic diff summarization is only supported for the Gemini class of models right now. Turn it on via:
1. Semantic diff summarization calls Gemini, OpenAI (or any OpenAI-compatible server, such as Ollama, via `endpoint`), or Anthropic. It is off by default. Turn it on via:
a. Through the TUI:
- Run `diffr config`
- Search for 'summarization'
- Enable in the dropdown
- Add your API key for Gemini if you don't already have on your path
- Pick a provider and model, and add its API key unless `GEMINI_API_KEY`, `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` is already in your environment
b. Through the [Whiteboard app](https://github.com/devdotfast/whiteboard).
2. The plugin API is a bit awkward and will be simplified radically in the coming releases.

Expand Down
14 changes: 7 additions & 7 deletions plugins/summarize/plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ description = "Pseudocode summaries for large new function bodies and tests."
# startup when a plugin that is on cannot be made.
[enabled]
title = "Summarize functions and tests"
description = "Summarize large new function bodies and right-side tests as pseudocode. Needs an API key: `api_key`, or `GEMINI_API_KEY` or `GOOGLE_API_KEY` in the environment."
description = "Summarize large new function bodies and right-side tests as pseudocode. Needs an API key: `api_key`, or the provider's variable in the environment: `GEMINI_API_KEY` or `GOOGLE_API_KEY`, `OPENAI_API_KEY`, or `ANTHROPIC_API_KEY`."
default = false

[options.provider]
enum = ["gemini"]
enum = ["gemini", "openai", "anthropic"]
title = "Provider"
description = "Which model API to call."
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."
default = "gemini"

[options.model]
Expand All @@ -32,12 +32,12 @@ default = 20
[options.api_key]
type = "string"
title = "API key"
description = "The provider's API key. `GEMINI_API_KEY` or `GOOGLE_API_KEY` in the environment is used when this is unset."
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"
title = "Endpoint URL"
description = "Override the provider's base URL, for proxies and tests."
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."

[options.request_timeout_ms]
type = "integer"
Expand Down Expand Up @@ -71,8 +71,8 @@ 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 \
possible: about one pseudocode line per five source lines, and never more than a third \
of the body's lines. When a fold lists a doc, also set "summary" to one sentence copied \
verbatim from that doc; otherwise leave it empty. Answer with a JSON array of \
{"id", "summary", "pseudocode"} objects, one per fold."""
verbatim from that doc; otherwise leave it empty. Answer with a JSON object whose \
"summaries" array holds one {"id", "summary", "pseudocode"} object per fold."""

[options.tests]
type = "boolean"
Expand Down
Binary file modified plugins/summarize/plugin.wasm
Binary file not shown.
26 changes: 19 additions & 7 deletions plugins/summarize/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ use diffr_plugin_sdk::anyhow::{self, anyhow};
use wasi::http::{outgoing_handler, types::*};
use wasi::io::streams::StreamError;

pub fn post(url: &str, key: &str, body: &str, timeout_ms: u64) -> anyhow::Result<(u16, Vec<u8>)> {
pub fn post(
url: &str,
headers: &[(&'static str, String)],
body: &str,
timeout_ms: u64,
) -> anyhow::Result<(u16, Vec<u8>)> {
let (scheme, rest) = url
.split_once("://")
.ok_or_else(|| anyhow!("invalid endpoint URL"))?;
Expand All @@ -14,12 +19,19 @@ pub fn post(url: &str, key: &str, body: &str, timeout_ms: u64) -> anyhow::Result
_ => anyhow::bail!("endpoint must use http or https"),
};
let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
let headers = Fields::from_list(&[
("content-type".into(), b"application/json".to_vec()),
("content-length".into(), body.len().to_string().into_bytes()),
("x-goog-api-key".into(), key.as_bytes().to_vec()),
])
.map_err(|e| anyhow!("HTTP headers: {e:?}"))?;
let mut fields = vec![
("content-type".to_owned(), b"application/json".to_vec()),
(
"content-length".to_owned(),
body.len().to_string().into_bytes(),
),
];
fields.extend(
headers
.iter()
.map(|(name, value)| ((*name).to_owned(), value.as_bytes().to_vec())),
);
let headers = Fields::from_list(&fields).map_err(|e| anyhow!("HTTP headers: {e:?}"))?;
let request = OutgoingRequest::new(headers);
request
.set_method(&Method::Post)
Expand Down
103 changes: 39 additions & 64 deletions plugins/summarize/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
//! The summarizer: large new function bodies become short pseudocode, shown
//! in place of the collapsed body.
//!
//! It needs an API key: `new` fails without one, naming how to set it or
//! turn the plugin off, which is why the bundled configuration ships it off.
//! It needs an API key for most providers: `new` fails without one, naming
//! how to set it or turn the plugin off, which is why the bundled
//! configuration ships it off.
//! Requests use WASI HTTP. The host calls one file at a time per instance.
use diffr_plugin_sdk::anyhow::{self, anyhow, Context as _};
use diffr_plugin_sdk::{
docstring_of, export, has_tag, is_fold, line_count, one_sided, walk, Annotation, Draft,
FileEntry, Move, Node, OtherSide, Pairing, Plugin, Region, Source,
};
use serde::Deserialize;
use serde_json::json;
use std::collections::BTreeMap;
use std::time::Duration;
mod http;
mod provider;
pub use provider::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 @@ -40,18 +42,10 @@ pub struct Options {
pub system_prompt: String,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
Gemini,
}

const DEFAULT_ENDPOINT: &str = "https://generativelanguage.googleapis.com";

/// The summarizer's options, API key and endpoint.
pub struct Summarize {
options: Options,
api_key: String,
api_key: Option<String>,
endpoint: String,
}

Expand Down Expand Up @@ -80,16 +74,6 @@ struct Summary {
}

impl Summarize {
fn url(&self) -> String {
match self.options.provider {
Provider::Gemini => format!(
"{}/v1beta/models/{}:generateContent",
self.endpoint.trim_end_matches('/'),
self.options.model
),
}
}

fn prompt(&self, path: &str, src: &str, folds: &[Request]) -> String {
let numbered: Vec<String> = src
.split_terminator('\n')
Expand Down Expand Up @@ -125,36 +109,22 @@ impl Summarize {
src: &str,
folds: &[Request],
) -> anyhow::Result<BTreeMap<u32, Summary>> {
let body = json!({
"systemInstruction": {"parts": [{"text": self.options.system_prompt}]},
"contents": [{"role": "user", "parts": [{"text": self.prompt(path, src, folds)}]}],
"generationConfig": {
"temperature": 0,
"maxOutputTokens": 600 * folds.len() + 200,
"thinkingConfig": {"thinkingBudget": 0},
"responseMimeType": "application/json",
"responseSchema": {
"type": "ARRAY",
"items": {
"type": "OBJECT",
"properties": {
"id": {"type": "INTEGER"},
"summary": {"type": "STRING"},
"pseudocode": {"type": "STRING"},
},
"required": ["id", "pseudocode"],
},
},
},
});
let url = self.url();
let provider = self.options.provider;
let body = provider.body(
&self.options.model,
&self.options.system_prompt,
&self.prompt(path, src, folds),
600 * folds.len() + 200,
);
let url = provider.url(&self.endpoint, &self.options.model);
let headers = provider.headers(self.api_key.as_deref());
let failed = |message: String| anyhow!("{}: {message}", self.options.model);
let text: serde_json::Value = {
let mut attempt = 0;
loop {
let result = http::post(
&url,
&self.api_key,
&headers,
&body.to_string(),
self.options.request_timeout_ms,
);
Expand Down Expand Up @@ -182,13 +152,11 @@ impl Summarize {
std::thread::sleep(Duration::from_millis(250 * (1 << attempt.min(6))));
}
};
let content = text["candidates"][0]["content"]["parts"]
.as_array()
.and_then(|parts| parts.last())
.and_then(|part| part["text"].as_str())
let content = provider
.text(&text)
.ok_or_else(|| failed("no text in the response".to_owned()))?;
let answers: Vec<Answer> =
serde_json::from_str(content).map_err(|error| failed(format!("{error}: {content}")))?;
let answers: Vec<Answer> = provider::answers(content)
.ok_or_else(|| failed(format!("no summaries in the answer: {content}")))?;
let mut texts = BTreeMap::new();
for answer in answers {
if !folds.iter().any(|fold| fold.id == answer.id) {
Expand Down Expand Up @@ -329,39 +297,46 @@ fn compresses(summary: &str, body: &[&str]) -> bool {
summary_lines * 2 <= body_lines
}

/// The API key: the `api_key` option, or else `GEMINI_API_KEY`, or else
/// `GOOGLE_API_KEY`, the first that is set and not empty.
fn resolve_key(config: &Options) -> anyhow::Result<String> {
/// The API key: the `api_key` option, or else the first of the provider's
/// environment variables that is set and not empty. `None` only where the
/// provider can go without one.
fn resolve_key(config: &Options, custom_endpoint: bool) -> anyhow::Result<Option<String>> {
let set = |key: &String| !key.is_empty();
if let Some(key) = config.api_key.clone().filter(set) {
return Ok(key);
return Ok(Some(key));
}
for variable in ["GEMINI_API_KEY", "GOOGLE_API_KEY"] {
let variables = config.provider.key_variables();
for variable in variables {
if let Some(key) = std::env::var_os(variable) {
let key = key
.into_string()
.map_err(|_| anyhow!("{variable} is not valid UTF-8"))?;
if set(&key) {
return Ok(key);
return Ok(Some(key));
}
}
}
if config.provider.key_optional(custom_endpoint) {
return Ok(None);
}
anyhow::bail!(
"no API key: set plugins.bundled.summarize.api_key, or GEMINI_API_KEY or GOOGLE_API_KEY in the environment, or turn the summarizer off with plugins.bundled.summarize.enabled = false"
"no API key: set plugins.bundled.summarize.api_key, or {} in the environment, or turn the summarizer off with plugins.bundled.summarize.enabled = false",
variables.join(" or ")
)
}

impl Plugin for Summarize {
type Options = Options;

fn new(options: Options) -> anyhow::Result<Self> {
let api_key = resolve_key(&options)?;
let endpoint = options
.endpoint
.clone()
.filter(|endpoint| !endpoint.is_empty());
let api_key = resolve_key(&options, endpoint.is_some())?;
Ok(Self {
api_key,
endpoint: options
.endpoint
.clone()
.unwrap_or_else(|| DEFAULT_ENDPOINT.to_owned()),
endpoint: endpoint.unwrap_or_else(|| options.provider.default_endpoint().to_owned()),
options,
})
}
Expand Down
Loading
Loading