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
1 change: 1 addition & 0 deletions diffr/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions diffr/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ bytes = "1.12"
wasmtime-wasi-http = { version = "49.0.2", default-features = false, features = ["p2", "p3", "default-send-request"] }
cliclack = "0.5.6"
dirs = "5"
base64 = "0.22"


[target.'cfg(not(any(windows, target_os = "illumos", target_os = "freebsd")))'.dependencies]
Expand Down
2 changes: 1 addition & 1 deletion diffr/crates/diffr-core/src/config/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ mod tests {
);
assert_eq!(
error("plugins.shape.bundled.summarize.provider", "mistral"),
"plugins.shape.bundled.summarize.provider: expected one of \"gemini\", \"openai\", \"anthropic\", got \"mistral\""
"plugins.shape.bundled.summarize.provider: expected one of \"gemini\", \"openai\", \"anthropic\", \"codex\", got \"mistral\""
);
assert_eq!(
error("plugins.shape.mine.enabled", "true"),
Expand Down
7 changes: 4 additions & 3 deletions diffr/docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,15 @@ use. The bundled plugins' settings:
| `deleted-bodies.min_lines` | 12 | Deleted bodies shorter than this stay open. |
| `removed-runs.min_lines` | 5 | Removed stretches shorter than this stay open. |
| `test-bodies.min_lines` | 3 | Test bodies shorter than this stay open. |
| `summarize.enabled` | false | LLM pseudocode summaries of large new bodies and tests. Needs a key. |
| `summarize.provider` | `gemini` | `gemini`, `openai` or `anthropic`. Changing it clears `api_key`. |
| `summarize.enabled` | false | LLM pseudocode summaries of large new bodies and tests. Needs a key or the Codex sign-in. |
| `summarize.provider` | `gemini` | `gemini`, `openai`, `anthropic` or `codex`. Changing it clears `api_key`. |
| `summarize.model` | the provider's | The model name sent to the provider. |
| `summarize.api_key` | unset | Without it, the provider's variable in the environment is used: `GEMINI_API_KEY` or `GOOGLE_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`. |
| `summarize.api_key` | unset | Without it, the provider's variable in the environment is used: `GEMINI_API_KEY` or `GOOGLE_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`. `codex` reads Codex's saved sign-in instead; `diffr config logins` says whether it is ready. |
| `summarize.endpoint` | the provider's | Another base URL: a proxy, OpenRouter, or a local OpenAI-compatible server with `provider = "openai"`. |
| `summarize.min_lines` | 20 | New bodies shorter than this are shown as code. |
| `summarize.tests` | true | Summarize new test bodies too. |
| `summarize.test_min_lines` | 20 | Shorter test bodies keep their ordinary labels. |
| `summarize.max_length_ratio` | 0.67 | Pseudocode longer than this fraction of the body is discarded. |
| `summarize.system_prompt` | built in | The instruction sent with each body. Change it to change the style of the summaries. |
| `summarize.request_timeout_ms` | 60000 | Timeout for each request. |
| `summarize.retries` | 3 | Retries after a timeout, rate limit or server error. |
Expand Down
22 changes: 18 additions & 4 deletions diffr/plugins/shape/summarize/plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ description = "Summarize large new function bodies and new tests as pseudocode.
default = false

[options.provider]
enum = ["gemini", "openai", "anthropic"]
"x-enum-titles" = ["Gemini", "OpenAI", "Anthropic"]
enum = ["gemini", "openai", "anthropic", "codex"]
"x-enum-titles" = ["Gemini", "OpenAI", "Anthropic", "Codex"]
title = "Provider"
description = "Which model API to call. `openai` also reaches OpenRouter and any other OpenAI-compatible server through `endpoint`."
description = "Which model API to call. `openai` also reaches OpenRouter and any other OpenAI-compatible server through `endpoint`. `codex` uses Codex's saved sign-in."
default = "gemini"

[options.model]
Expand All @@ -25,7 +25,7 @@ description = "The model name sent to the provider. Unset uses the provider's de

[options.model."x-default-by"]
key = "provider"
values = { gemini = "gemini-3.8-flash", openai = "gpt-6-luna", anthropic = "claude-haiku-5-5" }
values = { gemini = "gemini-3.8-flash", openai = "gpt-6-luna", anthropic = "claude-haiku-5-5", codex = "gpt-6-luna" }

[options.min_lines]
type = "integer"
Expand All @@ -34,6 +34,13 @@ title = "Shortest body to summarize (lines)"
description = "New function bodies shorter than this are shown as code, not summarized: pseudocode only pays off once the body is long."
default = 20

[options.max_length_ratio]
type = "number"
exclusiveMinimum = 0
title = "Longest summary"
description = "Summaries longer than this fraction of the body are discarded."
default = 0.67

[options.api_key]
type = "string"
"x-reset-by" = "provider"
Expand Down Expand Up @@ -151,6 +158,7 @@ additionalProperties = false
endpoint = { type = "string" }
key_variables = { type = "array", items = { type = "string" } }
keyless_custom_endpoint = { type = "boolean" }
login = { enum = ["codex"] }

[options.provider_details."x-default-by"]
key = "provider"
Expand All @@ -170,6 +178,12 @@ endpoint = "https://api.anthropic.com"
key_variables = ["ANTHROPIC_API_KEY"]
keyless_custom_endpoint = false

[options.provider_details."x-default-by".values.codex]
endpoint = "https://chatgpt.com/backend-api/codex"
key_variables = []
keyless_custom_endpoint = false
login = "codex"

# The fold queries for each language, as files in this folder.
[queries]
rust = "queries/rust.scm"
Expand Down
Binary file modified diffr/plugins/shape/summarize/plugin.wasm
Binary file not shown.
35 changes: 23 additions & 12 deletions diffr/plugins/shape/summarize/rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub struct Options {
pub min_lines: usize,
pub tests: bool,
pub test_min_lines: usize,
pub max_length_ratio: f64,
pub api_key: Option<String>,
pub endpoint: Option<String>,
pub request_timeout_ms: u64,
Expand Down Expand Up @@ -81,7 +82,7 @@ impl Summarize {
};
let headers = provider.headers(key);
let failed = |message: String| anyhow!("{}: {message}", self.options.model);
let text: serde_json::Value = {
let body = {
let mut attempt = 0;
loop {
let result = http::post(
Expand All @@ -92,10 +93,7 @@ impl Summarize {
)
.await;
let retry = match result {
Ok((status, body)) if (200..300).contains(&status) => {
break serde_json::from_slice(&body)
.map_err(|error| failed(error.to_string()))?;
}
Ok((status, body)) if (200..300).contains(&status) => break body,
Ok((status, _)) if status == 429 || status >= 500 => format!("HTTP {status}"),
Ok((status, body)) => {
return Err(failed(format!(
Expand All @@ -116,13 +114,23 @@ impl Summarize {
}
};
let pseudocode = provider
.text(&text)
.ok_or_else(|| failed("no text in the response".to_owned()))?
.trim();
.text(&body)
.ok_or_else(|| failed("no text in the response".to_owned()))?;
let pseudocode = unfenced(pseudocode.trim()).trim();
Ok((!pseudocode.is_empty()).then(|| pseudocode.to_owned()))
}
}

fn unfenced(text: &str) -> &str {
let Some((opening, rest)) = text.split_once('\n') else {
return text;
};
match rest.strip_suffix("```") {
Some(inner) if opening.starts_with("```") => inner,
_ => text,
}
}

/// The API key: the `api_key` option, or else the first of the provider's
/// environment variables that is set and not empty. `Missing` says why on
/// stderr.
Expand All @@ -146,6 +154,9 @@ fn resolve_key(config: &Options, custom_endpoint: bool) -> Key {
if config.provider_details.keyless_custom_endpoint && custom_endpoint {
return Key::NotRequired;
}
if config.provider_details.login.is_some() {
return Key::Missing;
}
eprintln!(
"summarize: off for this run: no API key: set plugins.shape.bundled.summarize.api_key, or {} in the environment, or turn the summarizer off with plugins.shape.bundled.summarize.enabled = false",
variables.join(" or ")
Expand Down Expand Up @@ -204,10 +215,10 @@ impl GuestPlugin for Summarize {
else {
return Ok(false);
};
// Pseudocode earns its place only when it is at most two thirds as
// long as the body; otherwise the body keeps its original visibility.
let size = |text: &str| text.chars().filter(|c| !c.is_whitespace()).count();
if size(&pseudocode) * 3 > size(&text) * 2 {
// Pseudocode earns its place only when it is short enough; otherwise
// the body keeps its original visibility.
let size = |text: &str| text.chars().filter(|c| !c.is_whitespace()).count() as f64;
if size(&pseudocode) > size(&text) * self.options.max_length_ratio {
return Ok(false);
}
cursor.set_collapsed(data.id, true)?;
Expand Down
54 changes: 46 additions & 8 deletions diffr/plugins/shape/summarize/rust/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ pub struct Details {
/// Whether a custom endpoint may go without a key, as OpenAI-compatible
/// servers such as Ollama often do.
pub keyless_custom_endpoint: bool,
#[serde(default)]
pub login: Option<String>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
Expand All @@ -22,6 +24,7 @@ pub enum Provider {
Gemini,
OpenAi,
Anthropic,
Codex,
}

impl Provider {
Expand All @@ -31,6 +34,7 @@ impl Provider {
Self::Gemini => format!("{endpoint}/v1beta/models/{model}:generateContent"),
Self::OpenAi => format!("{endpoint}/chat/completions"),
Self::Anthropic => format!("{endpoint}/v1/messages"),
Self::Codex => format!("{endpoint}/responses"),
}
}

Expand All @@ -41,6 +45,7 @@ impl Provider {
Self::Gemini => ("x-goog-api-key", key.to_owned()),
Self::OpenAi => ("authorization", format!("Bearer {key}")),
Self::Anthropic => ("x-api-key", key.to_owned()),
Self::Codex => ("authorization", format!("Bearer {key}")),
});
}
if self == Self::Anthropic {
Expand All @@ -52,6 +57,7 @@ impl Provider {
/// Only Gemini gets a temperature: current reasoning models reject one.
/// OpenAI gets no length limit either, since compatible servers name it
/// differently; Anthropic's limit also covers thinking, so it has a floor.
/// The ChatGPT backend only streams.
pub fn body(self, model: &str, system: &str, user: &str, max_tokens: usize) -> Value {
match self {
Self::Gemini => json!({
Expand All @@ -76,22 +82,54 @@ impl Provider {
"system": system,
"messages": [{"role": "user", "content": user}],
}),
Self::Codex => json!({
"model": model,
"instructions": system,
"input": [{"role": "user", "content": [{"type": "input_text", "text": user}]}],
"reasoning": {"effort": "low"},
"store": false,
"stream": true,
}),
}
}

pub fn text(self, response: &Value) -> Option<&str> {
match self {
Self::Gemini => response["candidates"][0]["content"]["parts"]
pub fn text(self, body: &[u8]) -> Option<String> {
let json = || serde_json::from_slice::<Value>(body).ok();
let text = match self {
Self::Codex => return completed(body),
Self::Gemini => json()?["candidates"][0]["content"]["parts"]
.as_array()?
.last()?["text"]
.as_str(),
Self::OpenAi => response["choices"][0]["message"]["content"].as_str(),
Self::Anthropic => response["content"]
.as_str()?
.to_owned(),
Self::OpenAi => json()?["choices"][0]["message"]["content"]
.as_str()?
.to_owned(),
Self::Anthropic => json()?["content"]
.as_array()?
.iter()
.rev()
.find(|block| block["type"] == "text")?["text"]
.as_str(),
}
.as_str()?
.to_owned(),
};
Some(text)
}
}

fn completed(body: &[u8]) -> Option<String> {
let event = String::from_utf8_lossy(body)
.lines()
.filter_map(|line| line.strip_prefix("data:"))
.filter_map(|data| serde_json::from_str::<Value>(data.trim_start()).ok())
.find(|event| event["type"] == "response.completed")?;
let text: String = event["response"]["output"]
.as_array()?
.iter()
.filter(|item| item["type"] == "message")
.flat_map(|item| item["content"].as_array().into_iter().flatten())
.filter(|part| part["type"] == "output_text")
.filter_map(|part| part["text"].as_str())
.collect();
Some(text)
}
6 changes: 6 additions & 0 deletions diffr/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ enum ConfigCommand {
#[arg(long)]
json: bool,
},
/// Print which agent sign-ins are ready, as JSON
Logins,
}

pub(crate) fn run(runtime: &tokio::runtime::Runtime) -> Result<i32> {
Expand Down Expand Up @@ -735,6 +737,10 @@ fn run_config(config: &ConfigArgs) -> Result<i32> {
})();
return config_result(&mut stdout, result, *json);
}
Some(ConfigCommand::Logins) => {
serde_json::to_writer_pretty(&mut stdout, &crate::login::status())?;
stdout.write_all(b"\n")?;
}
Some(ConfigCommand::Init { json, answers }) => {
return crate::init::run(*json, answers.as_deref())
}
Expand Down
23 changes: 20 additions & 3 deletions diffr/src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const KEY_INSTRUCTIONS: &str = "To enable pseudocode summarization, we need:
1. An API Key from a supported provider (OpenAI, Anthropic, or Gemini-API compatible)
2. This API key can also be sourced from the environment
3. Otherwise, if that's not convenient, you can grab an API key and provide it to your agent (securely).
Codex needs no key: diffr uses its saved ChatGPT sign-in.
";

/// The summarizer's options in `config schema`.
Expand Down Expand Up @@ -72,6 +73,7 @@ struct State {
config: Config,
/// Each provider's id, title and key variables, from the schema.
providers: Vec<(String, String, Vec<String>)>,
signed_in: Vec<String>,
}

impl State {
Expand Down Expand Up @@ -110,10 +112,19 @@ impl State {
)
})
.collect();
let signed_in = ids
.iter()
.filter_map(|id| {
let id = id.as_str()?;
let login = details[id]["login"].as_str()?;
crate::login::token(login).is_ok().then(|| id.to_owned())
})
.collect();
Ok(Self {
found: Agent::found(),
config: Config::load()?,
providers,
signed_in,
})
}

Expand All @@ -129,15 +140,18 @@ impl State {
.any(|(name, _)| name == "bundled.summarize")
}

/// The saved provider when summaries are on; otherwise the first with a
/// key, or the saved one.
/// The saved provider when summaries are on; otherwise Codex, the first
/// with a key, or the saved one.
fn provider(&self) -> String {
let saved = self.summarize()["provider"]
.as_str()
.expect("provider is a string");
if self.on() {
return saved.to_owned();
}
if self.signed_in.iter().any(|id| id == "codex") {
return "codex".to_owned();
}
self.providers
.iter()
.map(|(id, _, _)| id.as_str())
Expand All @@ -156,8 +170,11 @@ impl State {
}

/// Whether the summarizer has a key for `provider`: the saved one, which
/// belongs to the saved provider, or one in the environment.
/// belongs to the saved provider, one in the environment, or a sign-in.
fn has_key(&self, provider: &str) -> bool {
if self.signed_in.iter().any(|id| id == provider) {
return true;
}
let saved = self.summarize()["provider"] == provider
&& self
.summarize()
Expand Down
Loading
Loading