From 03264d67afe5fe4dc44e3f4e1e859fdcda7a5bd7 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Fri, 29 May 2026 10:44:15 -0700 Subject: [PATCH 01/24] Add Anthropic (Claude) API backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refac now defaults to the Claude Messages API (model claude-opus-4-8); OpenAI remains available via `provider = "openai"`. New `src/anthropic.rs` talks to the REST API with reqwest (no official Rust SDK): x-api-key + anthropic-version headers, top-level `system`, and required `max_tokens`. It adapts refac's flat message list to Anthropic's shape — lifts the system prompt out of messages and merges the consecutive user turns to satisfy user/assistant alternation — and marks the static system prompt + few-shot examples `cache_control: ephemeral` so repeat calls only pay for the varying input. Config gains `provider`, optional `model` (defaulted per provider), and `max_tokens`; secrets hold either/both keys. Co-Authored-By: Claude Opus 4.8 --- README.md | 19 +++- src/anthropic.rs | 235 ++++++++++++++++++++++++++++++++++++++++++++ src/config_files.rs | 92 +++++++++++------ src/main.rs | 102 ++++++++++++------- 4 files changed, 382 insertions(+), 66 deletions(-) create mode 100644 src/anthropic.rs diff --git a/README.md b/README.md index 3dd4f46..86eb095 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,22 @@ The workflow: - Run the command, write instructions on what you want changed. - Enjoy the sassy comments. -This tool calls the openai api. You'll need your own api key to use it. -Use `refac login` to enter your api key. It will be saved in your home directory -for future use. See [your api usage](https://platform.openai.com/account) . +This tool calls the Anthropic (Claude) API by default — bring your own API key. +Use `refac login` to enter it; the key is saved in your home directory for future +use. See [your API usage](https://console.anthropic.com/settings/usage). + +OpenAI is still supported: set `provider = "openai"` in +`~/.config/refac/config.toml` (or `REFAC_PROVIDER=openai`), then `refac login`. + +Config (`~/.config/refac/config.toml`, all optional): + +```toml +provider = "anthropic" # or "openai" +model = "claude-opus-4-8" # default per provider; or set REFAC_MODEL +max_tokens = 16000 # Anthropic only +``` + +Keys may also be supplied via `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` env vars. ## SETUP diff --git a/src/anthropic.rs b/src/anthropic.rs new file mode 100644 index 0000000..735204c --- /dev/null +++ b/src/anthropic.rs @@ -0,0 +1,235 @@ +//! Anthropic (Claude) Messages API backend. +//! +//! No official Rust SDK exists, so this talks to the REST API directly with +//! `reqwest` (blocking, same as the OpenAI client). Differences from OpenAI that +//! this module handles: +//! - auth via the `x-api-key` header (+ `anthropic-version`), not bearer auth +//! - the system prompt is a top-level `system` field, not a `system`-role message +//! - messages must alternate user/assistant, so consecutive same-role messages +//! (refac sends `user(selected)` + `user(transform)`) are merged into one turn +//! - prompt caching: the static system prompt + few-shot examples are marked +//! `cache_control: ephemeral` so repeated calls only pay for the varying input + +use std::time::Duration; + +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::api::Message; + +const API_URL: &str = "https://api.anthropic.com/v1/messages"; +const ANTHROPIC_VERSION: &str = "2023-06-01"; + +#[derive(Serialize)] +struct CacheControl { + #[serde(rename = "type")] + kind: &'static str, // "ephemeral" +} + +impl CacheControl { + fn ephemeral() -> Self { + CacheControl { kind: "ephemeral" } + } +} + +#[derive(Serialize)] +struct TextBlock { + #[serde(rename = "type")] + kind: &'static str, // "text" + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, +} + +impl TextBlock { + fn new(text: impl Into) -> Self { + TextBlock { + kind: "text", + text: text.into(), + cache_control: None, + } + } +} + +#[derive(Serialize)] +struct ChatMessage { + role: String, + content: Vec, +} + +#[derive(Serialize)] +struct MessagesRequest { + model: String, + max_tokens: u32, + #[serde(skip_serializing_if = "Vec::is_empty")] + system: Vec, + messages: Vec, +} + +#[derive(Deserialize)] +struct MessagesResponse { + content: Vec, +} + +#[derive(Deserialize)] +struct ResponseBlock { + #[serde(rename = "type")] + kind: String, + #[serde(default)] + text: String, +} + +/// Send a chat-style prompt to the Claude Messages API and return the text. +/// +/// `messages` is refac's flat message list (system + few-shot user/assistant +/// pairs + the trailing user turns); this splits out the system prompt, merges +/// consecutive same-role turns to satisfy Anthropic's alternation requirement, +/// and caches the static prefix. +pub fn complete( + api_key: &str, + model: &str, + max_tokens: u32, + messages: &[Message], +) -> anyhow::Result { + let req = build_request(model, max_tokens, messages); + + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(60 * 4)) + .build() + .context("building HTTP client")?; + + let response = client + .post(API_URL) + .header("x-api-key", api_key) + .header("anthropic-version", ANTHROPIC_VERSION) + .header("content-type", "application/json") + .json(&req) + .send() + .context("Failed to send request to Anthropic API")?; + + let status = response.status(); + let body = response + .json::() + .with_context(|| anyhow::anyhow!("Status: {status}. Failed to parse response body."))?; + + if !status.is_success() { + let pretty = serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()); + return Err(anyhow::anyhow!("Status: {status}. Body: {pretty}")); + } + + let parsed: MessagesResponse = serde_json::from_value(body.clone()) + .map_err(|e| anyhow::anyhow!("Error while parsing response: {e} Body: {body}"))?; + + let text: String = parsed + .content + .into_iter() + .filter(|b| b.kind == "text") + .map(|b| b.text) + .collect(); + + if text.is_empty() { + return Err(anyhow::anyhow!("Anthropic returned no text content.")); + } + + Ok(text) +} + +fn build_request(model: &str, max_tokens: u32, messages: &[Message]) -> MessagesRequest { + let mut system_text = String::new(); + let mut convo: Vec = Vec::new(); + + for m in messages { + if m.role == "system" { + if !system_text.is_empty() { + system_text.push_str("\n\n"); + } + system_text.push_str(&m.content); + continue; + } + // Merge consecutive same-role messages — Anthropic requires alternation, + // and refac sends two user turns (selected, then transform) back to back. + match convo.last_mut() { + Some(last) if last.role == m.role => last.content.push(TextBlock::new(&m.content)), + _ => convo.push(ChatMessage { + role: m.role.clone(), + content: vec![TextBlock::new(&m.content)], + }), + } + } + + // Cache the static prefix. A breakpoint on the system block caches the system + // prompt; a breakpoint on the last few-shot assistant turn caches everything + // through the examples (render order is system → messages). The trailing user + // input after it stays uncached, which is exactly what varies per call. + let mut system = Vec::new(); + if !system_text.is_empty() { + let mut block = TextBlock::new(system_text); + block.cache_control = Some(CacheControl::ephemeral()); + system.push(block); + } + if let Some(idx) = convo.iter().rposition(|m| m.role == "assistant") { + if let Some(block) = convo[idx].content.last_mut() { + block.cache_control = Some(CacheControl::ephemeral()); + } + } + + MessagesRequest { + model: model.to_string(), + max_tokens, + system, + messages: convo, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_request_shapes_anthropic_payload() { + // Mirrors what refac sends: system + one few-shot (user,user,assistant) + // then the two trailing user turns (selected, transform). + let msgs = vec![ + Message::system("SYS"), + Message::user("ex_selected"), + Message::user("ex_transform"), + Message::assistant("ex_result"), + Message::user("real_selected"), + Message::user("real_transform"), + ]; + + let req = build_request("claude-opus-4-8", 16000, &msgs); + let v = serde_json::to_value(&req).unwrap(); + + assert_eq!(v["model"], "claude-opus-4-8"); + assert_eq!(v["max_tokens"], 16000); + + // System is lifted out of messages and cached. + assert_eq!(v["system"][0]["text"], "SYS"); + assert_eq!(v["system"][0]["cache_control"]["type"], "ephemeral"); + + // Consecutive same-role turns are merged → user, assistant, user (alternates). + let m = v["messages"].as_array().unwrap(); + assert_eq!(m.len(), 3); + assert_eq!(m[0]["role"], "user"); + assert_eq!(m[0]["content"].as_array().unwrap().len(), 2); // two few-shot user blocks + assert_eq!(m[1]["role"], "assistant"); + assert_eq!(m[2]["role"], "user"); + assert_eq!(m[2]["content"].as_array().unwrap().len(), 2); // selected + transform + + // Cache breakpoint on the last few-shot assistant turn; the varying final + // user input is NOT cached. + assert_eq!(m[1]["content"][0]["cache_control"]["type"], "ephemeral"); + assert!(m[2]["content"][1].get("cache_control").is_none()); + } + + #[test] + fn no_system_yields_empty_system() { + let msgs = vec![Message::user("hi")]; + let req = build_request("claude-opus-4-8", 100, &msgs); + let v = serde_json::to_value(&req).unwrap(); + assert!(v.get("system").is_none()); // skipped when empty + assert_eq!(v["messages"][0]["role"], "user"); + } +} diff --git a/src/config_files.rs b/src/config_files.rs index 3489f19..9981fe4 100644 --- a/src/config_files.rs +++ b/src/config_files.rs @@ -7,26 +7,30 @@ fn base() -> Result { BaseDirectories::with_prefix("refac").map_err(Into::into) } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Default)] pub struct Secrets { - pub openai_api_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openai_api_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub anthropic_api_key: Option, } impl Secrets { + /// Load secrets from `secrets.toml`, with env vars (`OPENAI_API_KEY`, + /// `ANTHROPIC_API_KEY`) taking precedence. A missing file is not an error — + /// env vars alone are enough. pub fn load() -> anyhow::Result { - if let Ok(api_key) = std::env::var("OPENAI_API_KEY") { - return Ok(Secrets { - openai_api_key: api_key, - }); + let mut secrets: Secrets = match base()?.find_config_file("secrets.toml") { + Some(path) => toml::from_str(&fs::read_to_string(path)?)?, + None => Secrets::default(), + }; + if let Ok(key) = std::env::var("OPENAI_API_KEY") { + secrets.openai_api_key = Some(key); } - let path = base()? - .find_config_file("secrets.toml") - .ok_or(anyhow::anyhow!( - "No secrets.toml file found. Try logging in with 'refac login'.", - ))?; - let secrets = fs::read_to_string(path)?; - let ret: Secrets = toml::from_str(&secrets)?; - Ok(ret) + if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") { + secrets.anthropic_api_key = Some(key); + } + Ok(secrets) } pub fn save(&self) -> anyhow::Result<()> { @@ -36,37 +40,69 @@ impl Secrets { } } -#[derive(Serialize, Deserialize, Debug)] -pub struct Config { - #[serde(default = "default_model")] - pub model: String, +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Provider { + Anthropic, + Openai, +} + +fn default_provider() -> Provider { + Provider::Anthropic } -fn default_model() -> String { - "o1".to_string() +fn default_max_tokens() -> u32 { + 16000 +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct Config { + #[serde(default = "default_provider")] + pub provider: Provider, + /// Model id. If unset, a sensible default is chosen per provider (see `model()`). + #[serde(default)] + pub model: Option, + /// Max tokens to generate. Required by Anthropic; ignored by the OpenAI path. + #[serde(default = "default_max_tokens")] + pub max_tokens: u32, } impl Default for Config { fn default() -> Self { Config { - model: default_model(), + provider: default_provider(), + model: None, + max_tokens: default_max_tokens(), } } } impl Config { pub fn load() -> anyhow::Result { - let mut ret = match base()?.find_config_file("config.toml") { - Some(path) => { - let config = fs::read_to_string(path)?; - let ret: Config = toml::from_str(&config)?; - ret - } + let mut ret: Config = match base()?.find_config_file("config.toml") { + Some(path) => toml::from_str(&fs::read_to_string(path)?)?, None => Config::default(), }; + if let Ok(from_env) = std::env::var("REFAC_PROVIDER") { + ret.provider = match from_env.to_lowercase().as_str() { + "openai" => Provider::Openai, + _ => Provider::Anthropic, + }; + } if let Ok(from_env) = std::env::var("REFAC_MODEL") { - ret.model = from_env; + ret.model = Some(from_env); } Ok(ret) } + + /// Resolve the model id, defaulting per provider when unset. + pub fn model(&self) -> String { + match &self.model { + Some(m) => m.clone(), + None => match self.provider { + Provider::Anthropic => "claude-opus-4-8".to_string(), + Provider::Openai => "o1".to_string(), + }, + } + } } diff --git a/src/main.rs b/src/main.rs index e90d996..c6d5df3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,15 @@ +mod anthropic; mod api; mod api_client; mod config_files; mod prompt; use anyhow::Context; -use api::{ChatCompletionRequest, ChatCompletionResponse}; +use api::{ChatCompletionRequest, Message}; use api_client::Client; use clap::Parser; -use config_files::{Config, Secrets}; -use serde::{Deserialize, Serialize}; +use config_files::{Config, Provider, Secrets}; +use serde::Serialize; use std::{ fs::{create_dir_all, OpenOptions}, io::Write, @@ -17,7 +18,8 @@ use std::{ }; use xdg::BaseDirectories; -use crate::{api::Message, prompt::chat_prefix}; +use crate::prompt::chat_prefix; + #[derive(Parser)] #[clap(version, author, about)] struct Opts { @@ -27,7 +29,7 @@ struct Opts { #[derive(Parser)] enum SubCommand { - /// Save your openai api key for future use. + /// Save your API key for future use (for the provider set in config; Anthropic by default). Login, /// Apply the instructions encoded in `transform` to the text in `selected`. /// Get it? 'refac tor' @@ -50,12 +52,21 @@ fn run() -> anyhow::Result<()> { match opts.subcmd { SubCommand::Login => { - println!("https://platform.openai.com/account/api-keys"); - let api_key = rpassword::prompt_password("Enter your OpenAI API key:")?; - Secrets { - openai_api_key: api_key, + let config = Config::load()?; + let mut secrets = Secrets::load().unwrap_or_default(); + match config.provider { + Provider::Anthropic => { + println!("https://console.anthropic.com/settings/keys"); + let api_key = rpassword::prompt_password("Enter your Anthropic API key:")?; + secrets.anthropic_api_key = Some(api_key); + } + Provider::Openai => { + println!("https://platform.openai.com/account/api-keys"); + let api_key = rpassword::prompt_password("Enter your OpenAI API key:")?; + secrets.openai_api_key = Some(api_key); + } } - .save()?; + secrets.save()?; } SubCommand::Tor { selected, @@ -77,13 +88,50 @@ fn refactor( sc: &Secrets, config: &Config, ) -> anyhow::Result { - let client = Client::new(&sc.openai_api_key); let mut messages = chat_prefix(); messages.push(Message::user(&selected)); messages.push(Message::user(&transform)); + let model = config.model(); + + let output = match config.provider { + Provider::Anthropic => { + let key = sc.anthropic_api_key.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "No Anthropic API key found. Set ANTHROPIC_API_KEY or run 'refac login'." + ) + })?; + anthropic::complete(key, &model, config.max_tokens, &messages)? + } + Provider::Openai => { + let key = sc.openai_api_key.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "No OpenAI API key found. Set OPENAI_API_KEY or run 'refac login'." + ) + })?; + openai_complete(key, &model, messages)? + } + }; + + log( + LogEntry { + provider: format!("{:?}", config.provider), + model, + selected, + transform, + output: output.clone(), + }, + "logs", + )?; + + Ok(output) +} + +fn openai_complete(api_key: &str, model: &str, messages: Vec) -> anyhow::Result { + let client = Client::new(api_key); + let request = ChatCompletionRequest { - model: config.model.clone(), + model: model.to_string(), messages, temperature: None, top_p: None, @@ -99,23 +147,12 @@ fn refactor( let response = client.request(&request)?; - log( - LogEntry { - inp: request, - res: response.clone(), - }, - "logs", - )?; - - let transformed_text = response + response .choices .into_iter() .next() - .ok_or(anyhow::anyhow!("No choices returned."))? - .message - .content; - - Ok(transformed_text) + .ok_or(anyhow::anyhow!("No choices returned.")) + .map(|choice| choice.message.content) } fn log_location(title: &str) -> anyhow::Result { @@ -133,18 +170,13 @@ fn log_location(title: &str) -> anyhow::Result { Ok(ret) } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize)] struct LogEntry { - inp: ChatCompletionRequest, - res: ChatCompletionResponse, -} - -#[derive(Debug, Serialize, Deserialize)] -struct UndiffFailure { + provider: String, + model: String, selected: String, - diff: String, transform: String, - err: String, + output: String, } fn log(t: T, title: &str) -> anyhow::Result<()> { From 65da913343575f75a93decb1b445bf2a6b10d36c Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Fri, 29 May 2026 10:49:50 -0700 Subject: [PATCH 02/24] Drop empty text blocks; live-tested against Anthropic Some few-shot samples have an empty `selected`; Anthropic rejects empty text content blocks (OpenAI tolerated them), so skip empty messages when building the request. Add a REFAC_DEBUG env that dumps the request JSON. Verified end-to-end: 'Me like toast.' / 'Correct grammar.' -> 'I like toast.' --- src/anthropic.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/anthropic.rs b/src/anthropic.rs index 735204c..1067e78 100644 --- a/src/anthropic.rs +++ b/src/anthropic.rs @@ -94,6 +94,10 @@ pub fn complete( ) -> anyhow::Result { let req = build_request(model, max_tokens, messages); + if std::env::var("REFAC_DEBUG").is_ok() { + eprintln!("{}", serde_json::to_string_pretty(&req).unwrap_or_default()); + } + let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(60 * 4)) .build() @@ -140,6 +144,11 @@ fn build_request(model: &str, max_tokens: u32, messages: &[Message]) -> Messages let mut convo: Vec = Vec::new(); for m in messages { + // Anthropic rejects empty text blocks (some few-shot samples have an empty + // `selected`); the OpenAI path tolerated them. Drop empties here. + if m.content.is_empty() { + continue; + } if m.role == "system" { if !system_text.is_empty() { system_text.push_str("\n\n"); @@ -224,6 +233,29 @@ mod tests { assert!(m[2]["content"][1].get("cache_control").is_none()); } + #[test] + fn empty_text_blocks_are_dropped() { + // A few-shot sample with an empty `selected` must not produce an empty + // text block (Anthropic 400s on those). + let msgs = vec![ + Message::user(""), + Message::user("write hello world"), + Message::assistant("print('hello world')"), + Message::user("real input"), + Message::user(""), + ]; + let req = build_request("claude-opus-4-8", 100, &msgs); + let v = serde_json::to_value(&req).unwrap(); + // No empty text anywhere. + let s = serde_json::to_string(&v).unwrap(); + assert!(!s.contains(r#""text":"""#), "empty text block leaked: {s}"); + let m = v["messages"].as_array().unwrap(); + assert_eq!(m[0]["role"], "user"); + assert_eq!(m[0]["content"][0]["text"], "write hello world"); + assert_eq!(m[1]["role"], "assistant"); + assert_eq!(m[2]["content"][0]["text"], "real input"); + } + #[test] fn no_system_yields_empty_system() { let msgs = vec![Message::user("hi")]; From 1e2fb8eb1fccb837774353ef879454c7c1a73411 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 10:35:10 -0700 Subject: [PATCH 03/24] anthropic: log request via tracing::debug! instead of REFAC_DEBUG eprintln Per review: drop the ad-hoc REFAC_DEBUG env-gated eprintln and use the existing tracing setup, gated by the subscriber's level filter like the rest of the code. Co-Authored-By: Claude Opus 4.8 --- src/anthropic.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/anthropic.rs b/src/anthropic.rs index 1067e78..5f42669 100644 --- a/src/anthropic.rs +++ b/src/anthropic.rs @@ -94,9 +94,10 @@ pub fn complete( ) -> anyhow::Result { let req = build_request(model, max_tokens, messages); - if std::env::var("REFAC_DEBUG").is_ok() { - eprintln!("{}", serde_json::to_string_pretty(&req).unwrap_or_default()); - } + tracing::debug!( + "anthropic request: {}", + serde_json::to_string_pretty(&req).unwrap_or_default() + ); let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(60 * 4)) From c9474aea37e46e0132aae07fa389a3056f5c6144 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 11:01:46 -0700 Subject: [PATCH 04/24] config: infer provider from available API keys when unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: `provider` is now optional. When it isn't set explicitly (config file or REFAC_PROVIDER), resolve it from which keys are configured — only an OpenAI key -> OpenAI; Anthropic-only, both, or neither -> lean Anthropic. An explicit choice still wins. Adds resolve_provider() + tests. Co-Authored-By: Claude Opus 4.8 --- src/config_files.rs | 70 ++++++++++++++++++++++++++++++++++++++------- src/main.rs | 9 +++--- 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/src/config_files.rs b/src/config_files.rs index 9981fe4..24ada99 100644 --- a/src/config_files.rs +++ b/src/config_files.rs @@ -47,18 +47,16 @@ pub enum Provider { Openai, } -fn default_provider() -> Provider { - Provider::Anthropic -} - fn default_max_tokens() -> u32 { 16000 } #[derive(Serialize, Deserialize, Debug)] pub struct Config { - #[serde(default = "default_provider")] - pub provider: Provider, + /// Explicit provider choice. When unset, it is inferred from which API keys + /// are configured (see `resolve_provider`). + #[serde(default)] + pub provider: Option, /// Model id. If unset, a sensible default is chosen per provider (see `model()`). #[serde(default)] pub model: Option, @@ -70,7 +68,7 @@ pub struct Config { impl Default for Config { fn default() -> Self { Config { - provider: default_provider(), + provider: None, model: None, max_tokens: default_max_tokens(), } @@ -84,10 +82,10 @@ impl Config { None => Config::default(), }; if let Ok(from_env) = std::env::var("REFAC_PROVIDER") { - ret.provider = match from_env.to_lowercase().as_str() { + ret.provider = Some(match from_env.to_lowercase().as_str() { "openai" => Provider::Openai, _ => Provider::Anthropic, - }; + }); } if let Ok(from_env) = std::env::var("REFAC_MODEL") { ret.model = Some(from_env); @@ -95,14 +93,64 @@ impl Config { Ok(ret) } + /// Resolve the effective provider. An explicit choice (config file or + /// `REFAC_PROVIDER`) always wins; otherwise infer from which API keys are + /// configured, leaning Anthropic when both or neither are present. + pub fn resolve_provider(&self, secrets: &Secrets) -> Provider { + if let Some(p) = self.provider { + return p; + } + match ( + secrets.anthropic_api_key.is_some(), + secrets.openai_api_key.is_some(), + ) { + // Only an OpenAI key -> OpenAI. Anthropic-only, both, or neither -> Anthropic. + (false, true) => Provider::Openai, + _ => Provider::Anthropic, + } + } + /// Resolve the model id, defaulting per provider when unset. - pub fn model(&self) -> String { + pub fn model(&self, provider: Provider) -> String { match &self.model { Some(m) => m.clone(), - None => match self.provider { + None => match provider { Provider::Anthropic => "claude-opus-4-8".to_string(), Provider::Openai => "o1".to_string(), }, } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn secrets(anthropic: bool, openai: bool) -> Secrets { + Secrets { + anthropic_api_key: anthropic.then(|| "a".to_string()), + openai_api_key: openai.then(|| "o".to_string()), + } + } + + #[test] + fn provider_inferred_from_available_keys() { + let cfg = Config::default(); // provider unset + // Only OpenAI configured -> OpenAI. + assert_eq!(cfg.resolve_provider(&secrets(false, true)), Provider::Openai); + // Anthropic only, both, or neither -> lean Anthropic. + assert_eq!(cfg.resolve_provider(&secrets(true, false)), Provider::Anthropic); + assert_eq!(cfg.resolve_provider(&secrets(true, true)), Provider::Anthropic); + assert_eq!(cfg.resolve_provider(&secrets(false, false)), Provider::Anthropic); + } + + #[test] + fn explicit_provider_overrides_inference() { + let cfg = Config { + provider: Some(Provider::Openai), + ..Config::default() + }; + // Explicit choice wins even when only an Anthropic key is present. + assert_eq!(cfg.resolve_provider(&secrets(true, false)), Provider::Openai); + } +} diff --git a/src/main.rs b/src/main.rs index c6d5df3..ccd7d82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,7 +54,7 @@ fn run() -> anyhow::Result<()> { SubCommand::Login => { let config = Config::load()?; let mut secrets = Secrets::load().unwrap_or_default(); - match config.provider { + match config.resolve_provider(&secrets) { Provider::Anthropic => { println!("https://console.anthropic.com/settings/keys"); let api_key = rpassword::prompt_password("Enter your Anthropic API key:")?; @@ -92,9 +92,10 @@ fn refactor( messages.push(Message::user(&selected)); messages.push(Message::user(&transform)); - let model = config.model(); + let provider = config.resolve_provider(sc); + let model = config.model(provider); - let output = match config.provider { + let output = match provider { Provider::Anthropic => { let key = sc.anthropic_api_key.as_deref().ok_or_else(|| { anyhow::anyhow!( @@ -115,7 +116,7 @@ fn refactor( log( LogEntry { - provider: format!("{:?}", config.provider), + provider: format!("{:?}", provider), model, selected, transform, From 51d6466a7c1b49466cd2647fb626f498a9a1e819 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 11:03:53 -0700 Subject: [PATCH 05/24] config: drop the max_tokens setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: a `max_tokens` config knob is a representable invalid state — `provider = "openai"` + `max_tokens` does nothing (the OpenAI path ignores it). Remove it. The Messages API still requires `max_tokens`, so hardcode it as a constant in the anthropic module. Can be reintroduced later with real support in both backends. Co-Authored-By: Claude Opus 4.8 --- src/anthropic.rs | 24 ++++++++++++------------ src/config_files.rs | 8 -------- src/main.rs | 2 +- 3 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/anthropic.rs b/src/anthropic.rs index 5f42669..a039d9e 100644 --- a/src/anthropic.rs +++ b/src/anthropic.rs @@ -18,6 +18,11 @@ use serde_json::Value; use crate::api::Message; +/// `max_tokens` is required by the Messages API. It isn't a user-facing setting +/// (a config knob that only one provider honors is a representable invalid +/// state); hardcode a generous ceiling here. +const MAX_TOKENS: u32 = 16000; + const API_URL: &str = "https://api.anthropic.com/v1/messages"; const ANTHROPIC_VERSION: &str = "2023-06-01"; @@ -86,13 +91,8 @@ struct ResponseBlock { /// pairs + the trailing user turns); this splits out the system prompt, merges /// consecutive same-role turns to satisfy Anthropic's alternation requirement, /// and caches the static prefix. -pub fn complete( - api_key: &str, - model: &str, - max_tokens: u32, - messages: &[Message], -) -> anyhow::Result { - let req = build_request(model, max_tokens, messages); +pub fn complete(api_key: &str, model: &str, messages: &[Message]) -> anyhow::Result { + let req = build_request(model, messages); tracing::debug!( "anthropic request: {}", @@ -140,7 +140,7 @@ pub fn complete( Ok(text) } -fn build_request(model: &str, max_tokens: u32, messages: &[Message]) -> MessagesRequest { +fn build_request(model: &str, messages: &[Message]) -> MessagesRequest { let mut system_text = String::new(); let mut convo: Vec = Vec::new(); @@ -186,7 +186,7 @@ fn build_request(model: &str, max_tokens: u32, messages: &[Message]) -> Messages MessagesRequest { model: model.to_string(), - max_tokens, + max_tokens: MAX_TOKENS, system, messages: convo, } @@ -209,7 +209,7 @@ mod tests { Message::user("real_transform"), ]; - let req = build_request("claude-opus-4-8", 16000, &msgs); + let req = build_request("claude-opus-4-8", &msgs); let v = serde_json::to_value(&req).unwrap(); assert_eq!(v["model"], "claude-opus-4-8"); @@ -245,7 +245,7 @@ mod tests { Message::user("real input"), Message::user(""), ]; - let req = build_request("claude-opus-4-8", 100, &msgs); + let req = build_request("claude-opus-4-8", &msgs); let v = serde_json::to_value(&req).unwrap(); // No empty text anywhere. let s = serde_json::to_string(&v).unwrap(); @@ -260,7 +260,7 @@ mod tests { #[test] fn no_system_yields_empty_system() { let msgs = vec![Message::user("hi")]; - let req = build_request("claude-opus-4-8", 100, &msgs); + let req = build_request("claude-opus-4-8", &msgs); let v = serde_json::to_value(&req).unwrap(); assert!(v.get("system").is_none()); // skipped when empty assert_eq!(v["messages"][0]["role"], "user"); diff --git a/src/config_files.rs b/src/config_files.rs index 24ada99..9035d9c 100644 --- a/src/config_files.rs +++ b/src/config_files.rs @@ -47,10 +47,6 @@ pub enum Provider { Openai, } -fn default_max_tokens() -> u32 { - 16000 -} - #[derive(Serialize, Deserialize, Debug)] pub struct Config { /// Explicit provider choice. When unset, it is inferred from which API keys @@ -60,9 +56,6 @@ pub struct Config { /// Model id. If unset, a sensible default is chosen per provider (see `model()`). #[serde(default)] pub model: Option, - /// Max tokens to generate. Required by Anthropic; ignored by the OpenAI path. - #[serde(default = "default_max_tokens")] - pub max_tokens: u32, } impl Default for Config { @@ -70,7 +63,6 @@ impl Default for Config { Config { provider: None, model: None, - max_tokens: default_max_tokens(), } } } diff --git a/src/main.rs b/src/main.rs index ccd7d82..08ca437 100644 --- a/src/main.rs +++ b/src/main.rs @@ -102,7 +102,7 @@ fn refactor( "No Anthropic API key found. Set ANTHROPIC_API_KEY or run 'refac login'." ) })?; - anthropic::complete(key, &model, config.max_tokens, &messages)? + anthropic::complete(key, &model, &messages)? } Provider::Openai => { let key = sc.openai_api_key.as_deref().ok_or_else(|| { From 678410a65d0405074a04b6196601d6c7e5264b24 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 11:06:57 -0700 Subject: [PATCH 06/24] config: error on invalid REFAC_PROVIDER; bump OpenAI default to gpt-5.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: - REFAC_PROVIDER no longer silently falls back to Anthropic on an unrecognized value — it errors with the accepted options. - OpenAI default model o1 -> gpt-5.5 (current flagship). - Drop a WHAT doc-comment on model(). Co-Authored-By: Claude Opus 4.8 --- src/config_files.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/config_files.rs b/src/config_files.rs index 9035d9c..2f0e7a0 100644 --- a/src/config_files.rs +++ b/src/config_files.rs @@ -75,8 +75,11 @@ impl Config { }; if let Ok(from_env) = std::env::var("REFAC_PROVIDER") { ret.provider = Some(match from_env.to_lowercase().as_str() { + "anthropic" => Provider::Anthropic, "openai" => Provider::Openai, - _ => Provider::Anthropic, + other => anyhow::bail!( + "invalid REFAC_PROVIDER {other:?}; expected \"anthropic\" or \"openai\"" + ), }); } if let Ok(from_env) = std::env::var("REFAC_MODEL") { @@ -102,13 +105,12 @@ impl Config { } } - /// Resolve the model id, defaulting per provider when unset. pub fn model(&self, provider: Provider) -> String { match &self.model { Some(m) => m.clone(), None => match provider { Provider::Anthropic => "claude-opus-4-8".to_string(), - Provider::Openai => "o1".to_string(), + Provider::Openai => "gpt-5.5".to_string(), }, } } From 8321e6b3467d15f5df75570e6e145ddfcec871aa Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 11:13:24 -0700 Subject: [PATCH 07/24] login: take --provider flag, or pick interactively Per review: `refac login` now accepts `--provider anthropic|openai`; without it, the user selects a provider via a dialoguer prompt rather than the key-inference heuristic (you're choosing which key to add). Provider derives clap::ValueEnum. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 101 ++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 1 + src/config_files.rs | 2 +- src/main.rs | 24 ++++++++--- 4 files changed, 118 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 54ab64c..8ec513f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -241,6 +241,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -257,6 +270,19 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -280,12 +306,34 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -761,6 +809,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.7.5" @@ -884,7 +938,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.8", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -922,7 +976,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.8", + "socket2 0.6.3", "tracing", "windows-sys 0.59.0", ] @@ -977,6 +1031,7 @@ version = "0.1.2" dependencies = [ "anyhow", "clap", + "dialoguer", "itertools", "reqwest", "rpassword", @@ -1075,6 +1130,19 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.39" @@ -1129,7 +1197,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1253,6 +1321,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -1349,6 +1423,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1597,6 +1684,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" @@ -1778,7 +1871,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2fb68e0..5010192 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ reqwest = { version = "0.13", default-features = false, features = [ "json", ] } rpassword = "7.2.0" +dialoguer = "0.11" serde = { version = "1.0.154", features = ["derive"] } serde_json = "1.0.94" similar = "2.2.1" diff --git a/src/config_files.rs b/src/config_files.rs index 2f0e7a0..2b006c9 100644 --- a/src/config_files.rs +++ b/src/config_files.rs @@ -40,7 +40,7 @@ impl Secrets { } } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] #[serde(rename_all = "lowercase")] pub enum Provider { Anthropic, diff --git a/src/main.rs b/src/main.rs index 08ca437..d574e2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,8 +29,11 @@ struct Opts { #[derive(Parser)] enum SubCommand { - /// Save your API key for future use (for the provider set in config; Anthropic by default). - Login, + /// Save your API key for future use. Pass `--provider`, or pick one interactively. + Login { + #[clap(long)] + provider: Option, + }, /// Apply the instructions encoded in `transform` to the text in `selected`. /// Get it? 'refac tor' Tor { selected: String, transform: String }, @@ -51,10 +54,21 @@ fn run() -> anyhow::Result<()> { let opts: Opts = Opts::parse(); match opts.subcmd { - SubCommand::Login => { - let config = Config::load()?; + SubCommand::Login { provider } => { let mut secrets = Secrets::load().unwrap_or_default(); - match config.resolve_provider(&secrets) { + let provider = match provider { + Some(p) => p, + None => { + let choices = [Provider::Anthropic, Provider::Openai]; + let idx = dialoguer::Select::new() + .with_prompt("Which provider?") + .items(&["Anthropic", "OpenAI"]) + .default(0) + .interact()?; + choices[idx] + } + }; + match provider { Provider::Anthropic => { println!("https://console.anthropic.com/settings/keys"); let api_key = rpassword::prompt_password("Enter your Anthropic API key:")?; From 0681b6e74657c8d16976a31af371d3b30a64538b Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 11:19:10 -0700 Subject: [PATCH 08/24] anthropic: caller owns the cache-prefix boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the prompt-cache breakpoint was inferred from message structure (last assistant turn), which baked in refac's usage pattern. Take the static prefix length from the caller instead — refactor() passes chat_prefix().len(), the part that's fixed across calls. build_request places the breakpoint at that boundary and never groups a varying turn into the cached prefix. Also corrected stale comments (alternation is no longer required; merging is just grouping). Co-Authored-By: Claude Opus 4.8 --- src/anthropic.rs | 74 ++++++++++++++++++++++++++++++------------------ src/main.rs | 4 ++- 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/src/anthropic.rs b/src/anthropic.rs index a039d9e..8541426 100644 --- a/src/anthropic.rs +++ b/src/anthropic.rs @@ -5,10 +5,11 @@ //! this module handles: //! - auth via the `x-api-key` header (+ `anthropic-version`), not bearer auth //! - the system prompt is a top-level `system` field, not a `system`-role message -//! - messages must alternate user/assistant, so consecutive same-role messages -//! (refac sends `user(selected)` + `user(transform)`) are merged into one turn -//! - prompt caching: the static system prompt + few-shot examples are marked -//! `cache_control: ephemeral` so repeated calls only pay for the varying input +//! - consecutive same-role messages (refac sends `user(selected)` + +//! `user(transform)`) are grouped into one turn +//! - prompt caching: the caller-supplied static prefix (system prompt + +//! few-shot examples) is marked `cache_control: ephemeral` so repeated calls +//! only pay for the varying input use std::time::Duration; @@ -87,12 +88,15 @@ struct ResponseBlock { /// Send a chat-style prompt to the Claude Messages API and return the text. /// -/// `messages` is refac's flat message list (system + few-shot user/assistant -/// pairs + the trailing user turns); this splits out the system prompt, merges -/// consecutive same-role turns to satisfy Anthropic's alternation requirement, -/// and caches the static prefix. -pub fn complete(api_key: &str, model: &str, messages: &[Message]) -> anyhow::Result { - let req = build_request(model, messages); +/// `messages` is refac's flat message list; the leading `cache_prefix_len` of +/// them are the static prefix (see `build_request`). +pub fn complete( + api_key: &str, + model: &str, + messages: &[Message], + cache_prefix_len: usize, +) -> anyhow::Result { + let req = build_request(model, messages, cache_prefix_len); tracing::debug!( "anthropic request: {}", @@ -140,13 +144,22 @@ pub fn complete(api_key: &str, model: &str, messages: &[Message]) -> anyhow::Res Ok(text) } -fn build_request(model: &str, messages: &[Message]) -> MessagesRequest { +/// `cache_prefix_len` is the number of leading `messages` the caller considers +/// static — the prompt-caching breakpoint goes at the end of that prefix. The +/// caller owns this because only it knows what's fixed vs. per-call; the backend +/// doesn't infer it from message structure. +fn build_request(model: &str, messages: &[Message], cache_prefix_len: usize) -> MessagesRequest { let mut system_text = String::new(); let mut convo: Vec = Vec::new(); + // How many `convo` turns came from the cacheable prefix. + let mut prefix_turns: Option = None; - for m in messages { - // Anthropic rejects empty text blocks (some few-shot samples have an empty - // `selected`); the OpenAI path tolerated them. Drop empties here. + for (i, m) in messages.iter().enumerate() { + if i == cache_prefix_len { + prefix_turns = Some(convo.len()); + } + // Anthropic 400s on empty text blocks (some few-shot samples have an + // empty `selected`); the OpenAI path tolerated them. if m.content.is_empty() { continue; } @@ -157,31 +170,36 @@ fn build_request(model: &str, messages: &[Message]) -> MessagesRequest { system_text.push_str(&m.content); continue; } - // Merge consecutive same-role messages — Anthropic requires alternation, - // and refac sends two user turns (selected, then transform) back to back. + // Group consecutive same-role messages into one turn (refac sends the + // selected text and the transform instruction as two user turns). Never + // group across the prefix boundary, so the cached prefix turn can't + // absorb varying input. match convo.last_mut() { - Some(last) if last.role == m.role => last.content.push(TextBlock::new(&m.content)), + Some(last) if last.role == m.role && i != cache_prefix_len => { + last.content.push(TextBlock::new(&m.content)) + } _ => convo.push(ChatMessage { role: m.role.clone(), content: vec![TextBlock::new(&m.content)], }), } } + let prefix_turns = prefix_turns.unwrap_or(convo.len()); - // Cache the static prefix. A breakpoint on the system block caches the system - // prompt; a breakpoint on the last few-shot assistant turn caches everything - // through the examples (render order is system → messages). The trailing user - // input after it stays uncached, which is exactly what varies per call. let mut system = Vec::new(); if !system_text.is_empty() { let mut block = TextBlock::new(system_text); block.cache_control = Some(CacheControl::ephemeral()); system.push(block); } - if let Some(idx) = convo.iter().rposition(|m| m.role == "assistant") { - if let Some(block) = convo[idx].content.last_mut() { - block.cache_control = Some(CacheControl::ephemeral()); - } + // Cache through the last turn of the prefix; everything after it varies per + // call and stays uncached. + if let Some(block) = prefix_turns + .checked_sub(1) + .and_then(|idx| convo.get_mut(idx)) + .and_then(|turn| turn.content.last_mut()) + { + block.cache_control = Some(CacheControl::ephemeral()); } MessagesRequest { @@ -209,7 +227,7 @@ mod tests { Message::user("real_transform"), ]; - let req = build_request("claude-opus-4-8", &msgs); + let req = build_request("claude-opus-4-8", &msgs, 4); let v = serde_json::to_value(&req).unwrap(); assert_eq!(v["model"], "claude-opus-4-8"); @@ -245,7 +263,7 @@ mod tests { Message::user("real input"), Message::user(""), ]; - let req = build_request("claude-opus-4-8", &msgs); + let req = build_request("claude-opus-4-8", &msgs, 3); let v = serde_json::to_value(&req).unwrap(); // No empty text anywhere. let s = serde_json::to_string(&v).unwrap(); @@ -260,7 +278,7 @@ mod tests { #[test] fn no_system_yields_empty_system() { let msgs = vec![Message::user("hi")]; - let req = build_request("claude-opus-4-8", &msgs); + let req = build_request("claude-opus-4-8", &msgs, 0); let v = serde_json::to_value(&req).unwrap(); assert!(v.get("system").is_none()); // skipped when empty assert_eq!(v["messages"][0]["role"], "user"); diff --git a/src/main.rs b/src/main.rs index d574e2d..a4cd7c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -103,6 +103,8 @@ fn refactor( config: &Config, ) -> anyhow::Result { let mut messages = chat_prefix(); + // chat_prefix() is fixed across calls; only the selected/transform tail varies. + let cache_prefix_len = messages.len(); messages.push(Message::user(&selected)); messages.push(Message::user(&transform)); @@ -116,7 +118,7 @@ fn refactor( "No Anthropic API key found. Set ANTHROPIC_API_KEY or run 'refac login'." ) })?; - anthropic::complete(key, &model, &messages)? + anthropic::complete(key, &model, &messages, cache_prefix_len)? } Provider::Openai => { let key = sc.openai_api_key.as_deref().ok_or_else(|| { From e970886a83851942d2514dd8584b395955686a13 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 11:33:55 -0700 Subject: [PATCH 09/24] anthropic: drop the "no official Rust SDK" line from module docs Per review: that claim could go stale; omit it. Co-Authored-By: Claude Opus 4.8 --- src/anthropic.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/anthropic.rs b/src/anthropic.rs index 8541426..1be8ef7 100644 --- a/src/anthropic.rs +++ b/src/anthropic.rs @@ -1,8 +1,7 @@ //! Anthropic (Claude) Messages API backend. //! -//! No official Rust SDK exists, so this talks to the REST API directly with -//! `reqwest` (blocking, same as the OpenAI client). Differences from OpenAI that -//! this module handles: +//! Talks to the REST API directly with `reqwest` (blocking, same as the OpenAI +//! client). Differences from OpenAI that this module handles: //! - auth via the `x-api-key` header (+ `anthropic-version`), not bearer auth //! - the system prompt is a top-level `system` field, not a `system`-role message //! - consecutive same-role messages (refac sends `user(selected)` + From 16b3dd521122112f6d9930f937de60f388291f09 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 11:37:02 -0700 Subject: [PATCH 10/24] anthropic: trim module docs, drop max_tokens comment, enum the type tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: - module doc was tmi / drifting — cut to a one-liner. - removed the verbose MAX_TOKENS comment (kept the const; the API requires the field). - replaced the stringly-typed content/cache `type` tags with enums (BlockType, CacheType). Co-Authored-By: Claude Opus 4.8 --- src/anthropic.rs | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/anthropic.rs b/src/anthropic.rs index 1be8ef7..e704882 100644 --- a/src/anthropic.rs +++ b/src/anthropic.rs @@ -1,14 +1,4 @@ //! Anthropic (Claude) Messages API backend. -//! -//! Talks to the REST API directly with `reqwest` (blocking, same as the OpenAI -//! client). Differences from OpenAI that this module handles: -//! - auth via the `x-api-key` header (+ `anthropic-version`), not bearer auth -//! - the system prompt is a top-level `system` field, not a `system`-role message -//! - consecutive same-role messages (refac sends `user(selected)` + -//! `user(transform)`) are grouped into one turn -//! - prompt caching: the caller-supplied static prefix (system prompt + -//! few-shot examples) is marked `cache_control: ephemeral` so repeated calls -//! only pay for the varying input use std::time::Duration; @@ -18,30 +8,41 @@ use serde_json::Value; use crate::api::Message; -/// `max_tokens` is required by the Messages API. It isn't a user-facing setting -/// (a config knob that only one provider honors is a representable invalid -/// state); hardcode a generous ceiling here. const MAX_TOKENS: u32 = 16000; const API_URL: &str = "https://api.anthropic.com/v1/messages"; const ANTHROPIC_VERSION: &str = "2023-06-01"; +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +enum BlockType { + Text, +} + +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +enum CacheType { + Ephemeral, +} + #[derive(Serialize)] struct CacheControl { #[serde(rename = "type")] - kind: &'static str, // "ephemeral" + kind: CacheType, } impl CacheControl { fn ephemeral() -> Self { - CacheControl { kind: "ephemeral" } + CacheControl { + kind: CacheType::Ephemeral, + } } } #[derive(Serialize)] struct TextBlock { #[serde(rename = "type")] - kind: &'static str, // "text" + kind: BlockType, text: String, #[serde(skip_serializing_if = "Option::is_none")] cache_control: Option, @@ -50,7 +51,7 @@ struct TextBlock { impl TextBlock { fn new(text: impl Into) -> Self { TextBlock { - kind: "text", + kind: BlockType::Text, text: text.into(), cache_control: None, } From 538ae2b237c550b2012ca3346e789fe9ad552b99 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 11:40:28 -0700 Subject: [PATCH 11/24] anthropic: trim build_request doc to what readers need Per review: the comment carried review-conversation history ("the backend doesn't infer it from message structure") that's irrelevant to future readers. Co-Authored-By: Claude Opus 4.8 --- src/anthropic.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/anthropic.rs b/src/anthropic.rs index e704882..1ed1cc9 100644 --- a/src/anthropic.rs +++ b/src/anthropic.rs @@ -144,10 +144,8 @@ pub fn complete( Ok(text) } -/// `cache_prefix_len` is the number of leading `messages` the caller considers -/// static — the prompt-caching breakpoint goes at the end of that prefix. The -/// caller owns this because only it knows what's fixed vs. per-call; the backend -/// doesn't infer it from message structure. +/// `cache_prefix_len` is the number of leading `messages` that are static; the +/// prompt-caching breakpoint goes at the end of that prefix. fn build_request(model: &str, messages: &[Message], cache_prefix_len: usize) -> MessagesRequest { let mut system_text = String::new(); let mut convo: Vec = Vec::new(); From a88238f81ec9e0f3263d00c649862934578e28b1 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 11:44:15 -0700 Subject: [PATCH 12/24] config: drop WHAT comment in resolve_provider The match arms say it; the doc comment carries the why. Co-Authored-By: Claude Opus 4.8 --- src/config_files.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/config_files.rs b/src/config_files.rs index 2b006c9..8ea8951 100644 --- a/src/config_files.rs +++ b/src/config_files.rs @@ -99,7 +99,6 @@ impl Config { secrets.anthropic_api_key.is_some(), secrets.openai_api_key.is_some(), ) { - // Only an OpenAI key -> OpenAI. Anthropic-only, both, or neither -> Anthropic. (false, true) => Provider::Openai, _ => Provider::Anthropic, } From 898239a6dc68661b63a20e5fef255e54b2c2aeeb Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 11:47:50 -0700 Subject: [PATCH 13/24] config: rename resolve_provider -> provider Per review. Also drop the WHAT comments restating the test assertions. Co-Authored-By: Claude Opus 4.8 --- src/config_files.rs | 19 ++++++++----------- src/main.rs | 2 +- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/config_files.rs b/src/config_files.rs index 8ea8951..738ba2b 100644 --- a/src/config_files.rs +++ b/src/config_files.rs @@ -50,7 +50,7 @@ pub enum Provider { #[derive(Serialize, Deserialize, Debug)] pub struct Config { /// Explicit provider choice. When unset, it is inferred from which API keys - /// are configured (see `resolve_provider`). + /// are configured (see `provider`). #[serde(default)] pub provider: Option, /// Model id. If unset, a sensible default is chosen per provider (see `model()`). @@ -91,7 +91,7 @@ impl Config { /// Resolve the effective provider. An explicit choice (config file or /// `REFAC_PROVIDER`) always wins; otherwise infer from which API keys are /// configured, leaning Anthropic when both or neither are present. - pub fn resolve_provider(&self, secrets: &Secrets) -> Provider { + pub fn provider(&self, secrets: &Secrets) -> Provider { if let Some(p) = self.provider { return p; } @@ -128,13 +128,11 @@ mod tests { #[test] fn provider_inferred_from_available_keys() { - let cfg = Config::default(); // provider unset - // Only OpenAI configured -> OpenAI. - assert_eq!(cfg.resolve_provider(&secrets(false, true)), Provider::Openai); - // Anthropic only, both, or neither -> lean Anthropic. - assert_eq!(cfg.resolve_provider(&secrets(true, false)), Provider::Anthropic); - assert_eq!(cfg.resolve_provider(&secrets(true, true)), Provider::Anthropic); - assert_eq!(cfg.resolve_provider(&secrets(false, false)), Provider::Anthropic); + let cfg = Config::default(); + assert_eq!(cfg.provider(&secrets(false, true)), Provider::Openai); + assert_eq!(cfg.provider(&secrets(true, false)), Provider::Anthropic); + assert_eq!(cfg.provider(&secrets(true, true)), Provider::Anthropic); + assert_eq!(cfg.provider(&secrets(false, false)), Provider::Anthropic); } #[test] @@ -143,7 +141,6 @@ mod tests { provider: Some(Provider::Openai), ..Config::default() }; - // Explicit choice wins even when only an Anthropic key is present. - assert_eq!(cfg.resolve_provider(&secrets(true, false)), Provider::Openai); + assert_eq!(cfg.provider(&secrets(true, false)), Provider::Openai); } } diff --git a/src/main.rs b/src/main.rs index a4cd7c3..a7bdc4d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -108,7 +108,7 @@ fn refactor( messages.push(Message::user(&selected)); messages.push(Message::user(&transform)); - let provider = config.resolve_provider(sc); + let provider = config.provider(sc); let model = config.model(provider); let output = match provider { From f9821100c37d1a5aab3d1c8b160e1b58a6454751 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 11:51:11 -0700 Subject: [PATCH 14/24] login: derive provider labels from the choices list Per review: drop the mirrored label array; map choices through Debug instead. Also drop a comment per suggestion. Co-Authored-By: Claude Opus 4.8 --- src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index a7bdc4d..0e57279 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,9 +60,10 @@ fn run() -> anyhow::Result<()> { Some(p) => p, None => { let choices = [Provider::Anthropic, Provider::Openai]; + let labels: Vec = choices.iter().map(|p| format!("{p:?}")).collect(); let idx = dialoguer::Select::new() .with_prompt("Which provider?") - .items(&["Anthropic", "OpenAI"]) + .items(&labels) .default(0) .interact()?; choices[idx] @@ -103,7 +104,6 @@ fn refactor( config: &Config, ) -> anyhow::Result { let mut messages = chat_prefix(); - // chat_prefix() is fixed across calls; only the selected/transform tail varies. let cache_prefix_len = messages.len(); messages.push(Message::user(&selected)); messages.push(Message::user(&transform)); From f30572f48bf0c398c21c75996b771cf005cf0456 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 12:11:23 -0700 Subject: [PATCH 15/24] core: multi-field provider-agnostic Message; adapters do the converting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Message is now `{ role: Role, fields: Vec, cache: bool }` instead of a single-string OpenAI-shaped struct. A turn carries one or more text fields (a transform user turn is [selected, transform]); `cache` marks the last turn of a static prefix. - anthropic: each field -> a content block; empty fields render as `(empty)` (the API rejects empty text); `cache` -> cache_control on the turn's last block. Drops the old consecutive-same-role merge and the cache_prefix_len parameter — the data model carries both now. - openai: its own `OpenAiMessage` wire type; the adapter joins a turn's fields and drops `cache`. - prompt: chat_prefix marks its last message cached. Live-tested against Anthropic: a normal transform and an empty-selected generate ("find .") both work. 5 unit tests pass. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 25 +++++---- src/anthropic.rs | 134 ++++++++++++++--------------------------------- src/api.rs | 73 ++++++++++++++++++++------ src/main.rs | 27 +++++++--- src/prompt.rs | 15 ++++-- 5 files changed, 138 insertions(+), 136 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 10fd84b..65e2646 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -325,7 +325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -811,9 +811,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "linux-raw-sys" -version = "0.12.1" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "litemap" @@ -1132,15 +1132,15 @@ checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustix" -version = "1.1.4" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1197,7 +1197,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1425,15 +1425,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.27.0" +version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ + "cfg-if", "fastrand", - "getrandom 0.3.4", - "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1871,7 +1870,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/src/anthropic.rs b/src/anthropic.rs index 1ed1cc9..973a53d 100644 --- a/src/anthropic.rs +++ b/src/anthropic.rs @@ -6,7 +6,7 @@ use anyhow::Context; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::api::Message; +use crate::api::{field_or_placeholder, Message, Role}; const MAX_TOKENS: u32 = 16000; @@ -87,16 +87,8 @@ struct ResponseBlock { } /// Send a chat-style prompt to the Claude Messages API and return the text. -/// -/// `messages` is refac's flat message list; the leading `cache_prefix_len` of -/// them are the static prefix (see `build_request`). -pub fn complete( - api_key: &str, - model: &str, - messages: &[Message], - cache_prefix_len: usize, -) -> anyhow::Result { - let req = build_request(model, messages, cache_prefix_len); +pub fn complete(api_key: &str, model: &str, messages: &[Message]) -> anyhow::Result { + let req = build_request(model, messages); tracing::debug!( "anthropic request: {}", @@ -144,61 +136,30 @@ pub fn complete( Ok(text) } -/// `cache_prefix_len` is the number of leading `messages` that are static; the -/// prompt-caching breakpoint goes at the end of that prefix. -fn build_request(model: &str, messages: &[Message], cache_prefix_len: usize) -> MessagesRequest { - let mut system_text = String::new(); +fn build_request(model: &str, messages: &[Message]) -> MessagesRequest { + let mut system = Vec::new(); let mut convo: Vec = Vec::new(); - // How many `convo` turns came from the cacheable prefix. - let mut prefix_turns: Option = None; - for (i, m) in messages.iter().enumerate() { - if i == cache_prefix_len { - prefix_turns = Some(convo.len()); - } - // Anthropic 400s on empty text blocks (some few-shot samples have an - // empty `selected`); the OpenAI path tolerated them. - if m.content.is_empty() { - continue; - } - if m.role == "system" { - if !system_text.is_empty() { - system_text.push_str("\n\n"); + for m in messages { + let mut blocks: Vec = m + .fields + .iter() + .map(|f| TextBlock::new(field_or_placeholder(f))) + .collect(); + // A cached turn caches everything up to and including its last block. + if m.cache { + if let Some(block) = blocks.last_mut() { + block.cache_control = Some(CacheControl::ephemeral()); } - system_text.push_str(&m.content); - continue; } - // Group consecutive same-role messages into one turn (refac sends the - // selected text and the transform instruction as two user turns). Never - // group across the prefix boundary, so the cached prefix turn can't - // absorb varying input. - match convo.last_mut() { - Some(last) if last.role == m.role && i != cache_prefix_len => { - last.content.push(TextBlock::new(&m.content)) - } - _ => convo.push(ChatMessage { - role: m.role.clone(), - content: vec![TextBlock::new(&m.content)], + match m.role { + Role::System => system.extend(blocks), + Role::User | Role::Assistant => convo.push(ChatMessage { + role: m.role.as_str().to_string(), + content: blocks, }), } } - let prefix_turns = prefix_turns.unwrap_or(convo.len()); - - let mut system = Vec::new(); - if !system_text.is_empty() { - let mut block = TextBlock::new(system_text); - block.cache_control = Some(CacheControl::ephemeral()); - system.push(block); - } - // Cache through the last turn of the prefix; everything after it varies per - // call and stays uncached. - if let Some(block) = prefix_turns - .checked_sub(1) - .and_then(|idx| convo.get_mut(idx)) - .and_then(|turn| turn.content.last_mut()) - { - block.cache_control = Some(CacheControl::ephemeral()); - } MessagesRequest { model: model.to_string(), @@ -212,73 +173,56 @@ fn build_request(model: &str, messages: &[Message], cache_prefix_len: usize) -> mod tests { use super::*; + fn user(fields: &[&str]) -> Message { + Message::user(fields.iter().map(|f| f.to_string()).collect()) + } + #[test] fn build_request_shapes_anthropic_payload() { - // Mirrors what refac sends: system + one few-shot (user,user,assistant) - // then the two trailing user turns (selected, transform). + let mut assistant = Message::assistant("ex_result"); + assistant.cache = true; let msgs = vec![ Message::system("SYS"), - Message::user("ex_selected"), - Message::user("ex_transform"), - Message::assistant("ex_result"), - Message::user("real_selected"), - Message::user("real_transform"), + user(&["ex_selected", "ex_transform"]), + assistant, + user(&["real_selected", "real_transform"]), ]; - let req = build_request("claude-opus-4-8", &msgs, 4); + let req = build_request("claude-opus-4-8", &msgs); let v = serde_json::to_value(&req).unwrap(); assert_eq!(v["model"], "claude-opus-4-8"); assert_eq!(v["max_tokens"], 16000); - - // System is lifted out of messages and cached. assert_eq!(v["system"][0]["text"], "SYS"); - assert_eq!(v["system"][0]["cache_control"]["type"], "ephemeral"); - // Consecutive same-role turns are merged → user, assistant, user (alternates). let m = v["messages"].as_array().unwrap(); assert_eq!(m.len(), 3); assert_eq!(m[0]["role"], "user"); - assert_eq!(m[0]["content"].as_array().unwrap().len(), 2); // two few-shot user blocks + assert_eq!(m[0]["content"].as_array().unwrap().len(), 2); assert_eq!(m[1]["role"], "assistant"); assert_eq!(m[2]["role"], "user"); - assert_eq!(m[2]["content"].as_array().unwrap().len(), 2); // selected + transform + assert_eq!(m[2]["content"].as_array().unwrap().len(), 2); - // Cache breakpoint on the last few-shot assistant turn; the varying final - // user input is NOT cached. + // The cached turn carries the breakpoint; the trailing input does not. assert_eq!(m[1]["content"][0]["cache_control"]["type"], "ephemeral"); assert!(m[2]["content"][1].get("cache_control").is_none()); } #[test] - fn empty_text_blocks_are_dropped() { - // A few-shot sample with an empty `selected` must not produce an empty - // text block (Anthropic 400s on those). - let msgs = vec![ - Message::user(""), - Message::user("write hello world"), - Message::assistant("print('hello world')"), - Message::user("real input"), - Message::user(""), - ]; - let req = build_request("claude-opus-4-8", &msgs, 3); + fn empty_fields_become_placeholder() { + let req = build_request("claude-opus-4-8", &[user(&["", "transform"])]); let v = serde_json::to_value(&req).unwrap(); - // No empty text anywhere. let s = serde_json::to_string(&v).unwrap(); assert!(!s.contains(r#""text":"""#), "empty text block leaked: {s}"); - let m = v["messages"].as_array().unwrap(); - assert_eq!(m[0]["role"], "user"); - assert_eq!(m[0]["content"][0]["text"], "write hello world"); - assert_eq!(m[1]["role"], "assistant"); - assert_eq!(m[2]["content"][0]["text"], "real input"); + assert_eq!(v["messages"][0]["content"][0]["text"], "(empty)"); + assert_eq!(v["messages"][0]["content"][1]["text"], "transform"); } #[test] fn no_system_yields_empty_system() { - let msgs = vec![Message::user("hi")]; - let req = build_request("claude-opus-4-8", &msgs, 0); + let req = build_request("claude-opus-4-8", &[user(&["hi"])]); let v = serde_json::to_value(&req).unwrap(); - assert!(v.get("system").is_none()); // skipped when empty + assert!(v.get("system").is_none()); assert_eq!(v["messages"][0]["role"], "user"); } } diff --git a/src/api.rs b/src/api.rs index 67f16a4..b1cad37 100644 --- a/src/api.rs +++ b/src/api.rs @@ -76,36 +76,77 @@ pub struct Usage { pub total_tokens: u32, } -/// Represents a chat message. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + System, + User, + Assistant, +} + +impl Role { + pub fn as_str(self) -> &'static str { + match self { + Role::System => "system", + Role::User => "user", + Role::Assistant => "assistant", + } + } +} + +/// refac's provider-agnostic chat message. A turn carries one or more text +/// `fields` (a transform turn is `[selected, transform]`); each backend adapts +/// this to its own wire format. `cache` marks the last turn of a static prefix +/// so backends that support prompt caching can cache through it. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Message { - pub role: String, - pub content: String, + pub role: Role, + pub fields: Vec, + pub cache: bool, } impl Message { pub fn system>(content: S) -> Message { - Message { - role: "system".into(), - content: content.into(), - } + Message::single(Role::System, content) } - pub fn user>(content: S) -> Message { + pub fn assistant>(content: S) -> Message { + Message::single(Role::Assistant, content) + } + + pub fn user(fields: Vec) -> Message { Message { - role: "user".into(), - content: content.into(), + role: Role::User, + fields, + cache: false, } } - pub fn assistant>(content: S) -> Message { + fn single>(role: Role, content: S) -> Message { Message { - role: "assistant".into(), - content: content.into(), + role, + fields: vec![content.into()], + cache: false, } } } +/// Anthropic 400s on an empty text block, so render empty fields as a visible +/// placeholder. +pub fn field_or_placeholder(field: &str) -> &str { + if field.is_empty() { + "(empty)" + } else { + field + } +} + +/// A message in OpenAI's chat wire format (single `content` string). +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct OpenAiMessage { + pub role: String, + pub content: String, +} + /// Represents a request for a chat completion. /// /// A `ChatCompletionRequest` is used to generate completions for chat conversations @@ -116,7 +157,7 @@ pub struct ChatCompletionRequest { /// The ID of the model to use (e.g., "gpt-3.5-turbo"). pub model: String, /// The sequence of chat messages to generate completions for. - pub messages: Vec, + pub messages: Vec, /// The sampling temperature to use, between 0 and 2. Higher values make output more random, lower values make it more focused. #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, @@ -187,7 +228,7 @@ pub struct ChatChoice { /// The index of the chat choice. pub index: u32, /// The generated message, including the role ("assistant") and content. - pub message: Message, + pub message: OpenAiMessage, /// The reason why the conversation finished, e.g., "stop". pub finish_reason: String, } diff --git a/src/main.rs b/src/main.rs index 0e57279..f61d29c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ mod config_files; mod prompt; use anyhow::Context; -use api::{ChatCompletionRequest, Message}; +use api::{field_or_placeholder, ChatCompletionRequest, Message, OpenAiMessage}; use api_client::Client; use clap::Parser; use config_files::{Config, Provider, Secrets}; @@ -104,9 +104,7 @@ fn refactor( config: &Config, ) -> anyhow::Result { let mut messages = chat_prefix(); - let cache_prefix_len = messages.len(); - messages.push(Message::user(&selected)); - messages.push(Message::user(&transform)); + messages.push(Message::user(vec![selected.clone(), transform.clone()])); let provider = config.provider(sc); let model = config.model(provider); @@ -118,7 +116,7 @@ fn refactor( "No Anthropic API key found. Set ANTHROPIC_API_KEY or run 'refac login'." ) })?; - anthropic::complete(key, &model, &messages, cache_prefix_len)? + anthropic::complete(key, &model, &messages)? } Provider::Openai => { let key = sc.openai_api_key.as_deref().ok_or_else(|| { @@ -126,7 +124,7 @@ fn refactor( "No OpenAI API key found. Set OPENAI_API_KEY or run 'refac login'." ) })?; - openai_complete(key, &model, messages)? + openai_complete(key, &model, &messages)? } }; @@ -144,9 +142,24 @@ fn refactor( Ok(output) } -fn openai_complete(api_key: &str, model: &str, messages: Vec) -> anyhow::Result { +fn openai_complete(api_key: &str, model: &str, messages: &[Message]) -> anyhow::Result { let client = Client::new(api_key); + // OpenAI takes one string per message, so join a turn's fields with blank + // lines. `cache` has no OpenAI equivalent and is dropped. + let messages = messages + .iter() + .map(|m| OpenAiMessage { + role: m.role.as_str().to_string(), + content: m + .fields + .iter() + .map(|f| field_or_placeholder(f)) + .collect::>() + .join("\n\n"), + }) + .collect(); + let request = ChatCompletionRequest { model: model.to_string(), messages, diff --git a/src/prompt.rs b/src/prompt.rs index 8cab1f4..d6ae100 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -56,14 +56,19 @@ Be subversive, think critically, act in the user's best interest. "; pub fn chat_prefix() -> Vec { - let mut ret = Vec::new(); - - ret.push(Message::system(SYSTEM_PROMPT)); + let mut ret = vec![Message::system(SYSTEM_PROMPT)]; for sample in SAMPLES { - ret.push(Message::user(sample.selected)); - ret.push(Message::user(sample.transform)); + ret.push(Message::user(vec![ + sample.selected.to_string(), + sample.transform.to_string(), + ])); ret.push(Message::assistant(sample.result)); } + // This prefix is identical across calls; mark its end so a backend caches + // through it and only the appended user turn is billed each time. + if let Some(last) = ret.last_mut() { + last.cache = true; + } ret } From 0f7cc71ca4bc123d15c15e19d5dcd2de0bbb3a33 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 12:13:52 -0700 Subject: [PATCH 16/24] openai: emit each field as its own message, don't concat Per review: joining a turn's fields into one string blurs the selected text into the transform with no reliable separator. Send each field as a separate message instead (same as the pre-refactor OpenAI path). Co-Authored-By: Claude Opus 4.8 --- src/main.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/main.rs b/src/main.rs index f61d29c..a71df58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -145,18 +145,16 @@ fn refactor( fn openai_complete(api_key: &str, model: &str, messages: &[Message]) -> anyhow::Result { let client = Client::new(api_key); - // OpenAI takes one string per message, so join a turn's fields with blank - // lines. `cache` has no OpenAI equivalent and is dropped. - let messages = messages + // OpenAI takes one string per message; concatenating a turn's fields would + // blur the selected text into the transform with no reliable boundary, so + // emit each field as its own message. `cache` has no OpenAI equivalent. + let messages: Vec = messages .iter() - .map(|m| OpenAiMessage { - role: m.role.as_str().to_string(), - content: m - .fields - .iter() - .map(|f| field_or_placeholder(f)) - .collect::>() - .join("\n\n"), + .flat_map(|m| { + m.fields.iter().map(move |f| OpenAiMessage { + role: m.role.as_str().to_string(), + content: field_or_placeholder(f).to_string(), + }) }) .collect(); From f10ab4097c9ed302639ecc6adcdc92a328bc06b9 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 12:41:51 -0700 Subject: [PATCH 17/24] anthropic: raise MAX_TOKENS to 80000 ~$2.00 of Opus 4.8 output at $25/M. Verified the API accepts it for the model. Co-Authored-By: Claude Opus 4.8 --- src/anthropic.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/anthropic.rs b/src/anthropic.rs index 973a53d..516dfc5 100644 --- a/src/anthropic.rs +++ b/src/anthropic.rs @@ -8,7 +8,7 @@ use serde_json::Value; use crate::api::{field_or_placeholder, Message, Role}; -const MAX_TOKENS: u32 = 16000; +const MAX_TOKENS: u32 = 80000; const API_URL: &str = "https://api.anthropic.com/v1/messages"; const ANTHROPIC_VERSION: &str = "2023-06-01"; @@ -192,7 +192,7 @@ mod tests { let v = serde_json::to_value(&req).unwrap(); assert_eq!(v["model"], "claude-opus-4-8"); - assert_eq!(v["max_tokens"], 16000); + assert_eq!(v["max_tokens"], 80000); assert_eq!(v["system"][0]["text"], "SYS"); let m = v["messages"].as_array().unwrap(); From a3c4be90b2fd1ca9e46d5e3ebdfd3db917373dd3 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 12:47:21 -0700 Subject: [PATCH 18/24] anthropic: represent type-tagged wire shapes as serde enums Per review: - CacheControl and the content block are internally-tagged unions; use `#[serde(tag = "type")]` enums instead of a struct with a manual `type` field. - message roles are `Role` enums (anthropic ChatMessage and OpenAiMessage), not strings; Role gains a lowercase serde repr and `as_str` goes away. - the response block is a tagged enum (`Text { text } | Other`) rather than a stringly `kind` compared against "text". Co-Authored-By: Claude Opus 4.8 --- src/anthropic.rs | 76 ++++++++++++++++++------------------------------ src/api.rs | 15 ++-------- src/main.rs | 2 +- 3 files changed, 33 insertions(+), 60 deletions(-) diff --git a/src/anthropic.rs b/src/anthropic.rs index 516dfc5..7a921f5 100644 --- a/src/anthropic.rs +++ b/src/anthropic.rs @@ -14,44 +14,24 @@ const API_URL: &str = "https://api.anthropic.com/v1/messages"; const ANTHROPIC_VERSION: &str = "2023-06-01"; #[derive(Serialize)] -#[serde(rename_all = "lowercase")] -enum BlockType { - Text, -} - -#[derive(Serialize)] -#[serde(rename_all = "lowercase")] -enum CacheType { +#[serde(tag = "type", rename_all = "lowercase")] +enum CacheControl { Ephemeral, } #[derive(Serialize)] -struct CacheControl { - #[serde(rename = "type")] - kind: CacheType, -} - -impl CacheControl { - fn ephemeral() -> Self { - CacheControl { - kind: CacheType::Ephemeral, - } - } -} - -#[derive(Serialize)] -struct TextBlock { - #[serde(rename = "type")] - kind: BlockType, - text: String, - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, +#[serde(tag = "type", rename_all = "lowercase")] +enum ContentBlock { + Text { + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, + }, } -impl TextBlock { - fn new(text: impl Into) -> Self { - TextBlock { - kind: BlockType::Text, +impl ContentBlock { + fn text(text: impl Into) -> Self { + ContentBlock::Text { text: text.into(), cache_control: None, } @@ -60,8 +40,8 @@ impl TextBlock { #[derive(Serialize)] struct ChatMessage { - role: String, - content: Vec, + role: Role, + content: Vec, } #[derive(Serialize)] @@ -69,7 +49,7 @@ struct MessagesRequest { model: String, max_tokens: u32, #[serde(skip_serializing_if = "Vec::is_empty")] - system: Vec, + system: Vec, messages: Vec, } @@ -79,11 +59,11 @@ struct MessagesResponse { } #[derive(Deserialize)] -struct ResponseBlock { - #[serde(rename = "type")] - kind: String, - #[serde(default)] - text: String, +#[serde(tag = "type", rename_all = "lowercase")] +enum ResponseBlock { + Text { text: String }, + #[serde(other)] + Other, } /// Send a chat-style prompt to the Claude Messages API and return the text. @@ -125,8 +105,10 @@ pub fn complete(api_key: &str, model: &str, messages: &[Message]) -> anyhow::Res let text: String = parsed .content .into_iter() - .filter(|b| b.kind == "text") - .map(|b| b.text) + .filter_map(|b| match b { + ResponseBlock::Text { text } => Some(text), + ResponseBlock::Other => None, + }) .collect(); if text.is_empty() { @@ -141,21 +123,21 @@ fn build_request(model: &str, messages: &[Message]) -> MessagesRequest { let mut convo: Vec = Vec::new(); for m in messages { - let mut blocks: Vec = m + let mut blocks: Vec = m .fields .iter() - .map(|f| TextBlock::new(field_or_placeholder(f))) + .map(|f| ContentBlock::text(field_or_placeholder(f))) .collect(); // A cached turn caches everything up to and including its last block. if m.cache { - if let Some(block) = blocks.last_mut() { - block.cache_control = Some(CacheControl::ephemeral()); + if let Some(ContentBlock::Text { cache_control, .. }) = blocks.last_mut() { + *cache_control = Some(CacheControl::Ephemeral); } } match m.role { Role::System => system.extend(blocks), Role::User | Role::Assistant => convo.push(ChatMessage { - role: m.role.as_str().to_string(), + role: m.role, content: blocks, }), } diff --git a/src/api.rs b/src/api.rs index b1cad37..da44e0b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -76,23 +76,14 @@ pub struct Usage { pub total_tokens: u32, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum Role { System, User, Assistant, } -impl Role { - pub fn as_str(self) -> &'static str { - match self { - Role::System => "system", - Role::User => "user", - Role::Assistant => "assistant", - } - } -} - /// refac's provider-agnostic chat message. A turn carries one or more text /// `fields` (a transform turn is `[selected, transform]`); each backend adapts /// this to its own wire format. `cache` marks the last turn of a static prefix @@ -143,7 +134,7 @@ pub fn field_or_placeholder(field: &str) -> &str { /// A message in OpenAI's chat wire format (single `content` string). #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct OpenAiMessage { - pub role: String, + pub role: Role, pub content: String, } diff --git a/src/main.rs b/src/main.rs index a71df58..c59d3a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -152,7 +152,7 @@ fn openai_complete(api_key: &str, model: &str, messages: &[Message]) -> anyhow:: .iter() .flat_map(|m| { m.fields.iter().map(move |f| OpenAiMessage { - role: m.role.as_str().to_string(), + role: m.role, content: field_or_placeholder(f).to_string(), }) }) From f957cadb0f6f528f7e36a56d0a5baaf889962412 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 12:50:53 -0700 Subject: [PATCH 19/24] split provider wire code out of api.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api.rs is now just the provider-agnostic core (`Message`, `Role`). OpenAI's wire types and `complete` move to a new openai.rs; the Anthropic-only `field_or_placeholder` moves into anthropic.rs (and OpenAI no longer applies it — it tolerates empty content). Also drops the unused OpenAI edits-API types (EditRequest/EditResponse/Choice), which were dead. Co-Authored-By: Claude Opus 4.8 --- src/anthropic.rs | 12 +++- src/api.rs | 179 ----------------------------------------------- src/main.rs | 47 +------------ src/openai.rs | 116 ++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 224 deletions(-) create mode 100644 src/openai.rs diff --git a/src/anthropic.rs b/src/anthropic.rs index 7a921f5..4ea7c89 100644 --- a/src/anthropic.rs +++ b/src/anthropic.rs @@ -6,10 +6,20 @@ use anyhow::Context; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::api::{field_or_placeholder, Message, Role}; +use crate::api::{Message, Role}; const MAX_TOKENS: u32 = 80000; +/// Anthropic 400s on an empty text block, so render empty fields as a visible +/// placeholder. +fn field_or_placeholder(field: &str) -> &str { + if field.is_empty() { + "(empty)" + } else { + field + } +} + const API_URL: &str = "https://api.anthropic.com/v1/messages"; const ANTHROPIC_VERSION: &str = "2023-06-01"; diff --git a/src/api.rs b/src/api.rs index da44e0b..8dd2e81 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,81 +1,5 @@ -use std::collections::HashMap; - -use reqwest::Method; use serde::{Deserialize, Serialize}; -use crate::api_client::{Endpoint, Req}; - -/// Represents a request for an edit. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct EditRequest { - /// ID of the model to use. You can use the text-davinci-edit-001 or - /// code-davinci-edit-001 model with this endpoint. - pub model: String, - /// The input text to use as a starting point for the edit. Defaults to an - /// empty string. - #[serde(skip_serializing_if = "Option::is_none")] - pub input: Option, - /// The instruction that tells the model how to edit the prompt. - pub instruction: String, - /// How many edits to generate for the input and instruction. Defaults to 1. - #[serde(skip_serializing_if = "Option::is_none")] - pub n: Option, - /// What sampling temperature to use, between 0 and 2. Higher values like - /// 0.8 will make the output more random, while lower values like 0.2 will - /// make it more focused and deterministic. Defaults to 1. - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, - /// An alternative to sampling with temperature, called nucleus sampling, - /// where the model considers the results of the tokens with top_p - /// probability mass. So 0.1 means only the tokens comprising the top 10% - /// probability mass are considered. Defaults to 1. - #[serde(skip_serializing_if = "Option::is_none")] - pub top_p: Option, -} - -impl Endpoint for EditRequest { - type Response = EditResponse; - - fn req(&self) -> Req { - Req::new(Method::POST, "/v1/edits") - .header("Content-Type", "application/json") - .json(self) - } -} - -/// Represents a response from the "edits" endpoint. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct EditResponse { - /// The object type, in this case, "edit". - pub object: String, - /// The timestamp when the edit was created. - pub created: u64, - /// A vector of the generated edit choices. - pub choices: Vec, - /// Information about token usage. - pub usage: Usage, -} - -/// Represents an individual edit choice. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct Choice { - /// The edited text. - pub text: String, - /// The index of the choice in the response. - pub index: u32, -} - -/// Represents the token usage information in the response. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct Usage { - /// The number of tokens used for the prompt. - pub prompt_tokens: u32, - /// The number of tokens used for the completion. - pub completion_tokens: Option, - /// The total number of tokens used. - pub total_tokens: u32, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Role { @@ -120,106 +44,3 @@ impl Message { } } } - -/// Anthropic 400s on an empty text block, so render empty fields as a visible -/// placeholder. -pub fn field_or_placeholder(field: &str) -> &str { - if field.is_empty() { - "(empty)" - } else { - field - } -} - -/// A message in OpenAI's chat wire format (single `content` string). -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct OpenAiMessage { - pub role: Role, - pub content: String, -} - -/// Represents a request for a chat completion. -/// -/// A `ChatCompletionRequest` is used to generate completions for chat conversations -/// with the OpenAI API. It contains various parameters that allow -/// control over the behavior of the model, such as temperature, top_p, and max_tokens. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct ChatCompletionRequest { - /// The ID of the model to use (e.g., "gpt-3.5-turbo"). - pub model: String, - /// The sequence of chat messages to generate completions for. - pub messages: Vec, - /// The sampling temperature to use, between 0 and 2. Higher values make output more random, lower values make it more focused. - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, - /// The proportion of probability mass to consider when generating completions. Only tokens comprising the top_p probability mass are considered. - #[serde(skip_serializing_if = "Option::is_none")] - pub top_p: Option, - /// The number of chat completion choices to generate for each input message. - #[serde(skip_serializing_if = "Option::is_none")] - pub n: Option, - /// Whether to enable streaming mode, receiving partial message deltas and tokens as soon as they're available. - #[serde(skip_serializing_if = "Option::is_none")] - pub stream: Option, - /// Up to 4 sequences where the API will stop generating further tokens. - #[serde(skip_serializing_if = "Option::is_none")] - pub stop: Option>, - /// The maximum number of tokens to generate in the chat completion. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_tokens: Option, - /// A positive value will penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics. - #[serde(skip_serializing_if = "Option::is_none")] - pub presence_penalty: Option, - /// A positive value will penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim. - #[serde(skip_serializing_if = "Option::is_none")] - pub frequency_penalty: Option, - /// A JSON object that maps tokens to an associated bias value from -100 to 100, modifying the likelihood of specified tokens appearing in the completion. - #[serde(skip_serializing_if = "Option::is_none")] - pub logit_bias: Option>, - /// A unique identifier representing your end-user, helping OpenAI monitor and detect abuse. - #[serde(skip_serializing_if = "Option::is_none")] - pub user: Option, -} - -impl Endpoint for ChatCompletionRequest { - type Response = ChatCompletionResponse; - - fn req(&self) -> Req { - Req::new(Method::POST, "/v1/chat/completions") - .header("Content-Type", "application/json") - .json(self) - } -} - -/// Represents a response from the "chat/completions" endpoint. -/// -/// This struct is returned after sending a ChatCompletionRequest to the OpenAI API. -/// It contains the generated chat completion choices and information about API usage. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct ChatCompletionResponse { - /// The ID of the chat completion. - pub id: String, - /// The object type (e.g., "chat.completion"). - pub object: String, - /// The timestamp when the chat completion was created. - pub created: u64, - /// The generated chat completion choices. - pub choices: Vec, - /// Information about the API usage, including prompt, completion, and total token counts. - pub usage: Usage, -} - -/// Represents an individual chat choice. -/// -/// A `ChatChoice` is part of the `ChatCompletionResponse` and contains information about -/// an individual choice generated by the model, such as the generated message and the -/// reason the conversation finished. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct ChatChoice { - /// The index of the chat choice. - pub index: u32, - /// The generated message, including the role ("assistant") and content. - pub message: OpenAiMessage, - /// The reason why the conversation finished, e.g., "stop". - pub finish_reason: String, -} diff --git a/src/main.rs b/src/main.rs index c59d3a6..0fe5162 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,11 +2,11 @@ mod anthropic; mod api; mod api_client; mod config_files; +mod openai; mod prompt; use anyhow::Context; -use api::{field_or_placeholder, ChatCompletionRequest, Message, OpenAiMessage}; -use api_client::Client; +use api::Message; use clap::Parser; use config_files::{Config, Provider, Secrets}; use serde::Serialize; @@ -124,7 +124,7 @@ fn refactor( "No OpenAI API key found. Set OPENAI_API_KEY or run 'refac login'." ) })?; - openai_complete(key, &model, &messages)? + openai::complete(key, &model, &messages)? } }; @@ -142,47 +142,6 @@ fn refactor( Ok(output) } -fn openai_complete(api_key: &str, model: &str, messages: &[Message]) -> anyhow::Result { - let client = Client::new(api_key); - - // OpenAI takes one string per message; concatenating a turn's fields would - // blur the selected text into the transform with no reliable boundary, so - // emit each field as its own message. `cache` has no OpenAI equivalent. - let messages: Vec = messages - .iter() - .flat_map(|m| { - m.fields.iter().map(move |f| OpenAiMessage { - role: m.role, - content: field_or_placeholder(f).to_string(), - }) - }) - .collect(); - - let request = ChatCompletionRequest { - model: model.to_string(), - messages, - temperature: None, - top_p: None, - n: None, - stream: None, - stop: None, - max_tokens: None, - presence_penalty: None, - frequency_penalty: None, - logit_bias: None, - user: None, - }; - - let response = client.request(&request)?; - - response - .choices - .into_iter() - .next() - .ok_or(anyhow::anyhow!("No choices returned.")) - .map(|choice| choice.message.content) -} - fn log_location(title: &str) -> anyhow::Result { let bd = BaseDirectories::with_prefix("refac")?; let ret = bd.get_data_file(format!("{title}.jsonl")); diff --git a/src/openai.rs b/src/openai.rs new file mode 100644 index 0000000..1f78d00 --- /dev/null +++ b/src/openai.rs @@ -0,0 +1,116 @@ +//! OpenAI chat-completions backend and its wire types. + +use std::collections::HashMap; + +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +use crate::api::{Message, Role}; +use crate::api_client::{Client, Endpoint, Req}; + +/// Send refac's messages to the OpenAI chat-completions API and return the text. +pub fn complete(api_key: &str, model: &str, messages: &[Message]) -> anyhow::Result { + let client = Client::new(api_key); + + // OpenAI takes one string per message; sending each field as its own message + // keeps a boundary between the selected text and the transform. + let messages: Vec = messages + .iter() + .flat_map(|m| { + m.fields.iter().map(move |f| OpenAiMessage { + role: m.role, + content: f.clone(), + }) + }) + .collect(); + + let request = ChatCompletionRequest { + model: model.to_string(), + messages, + temperature: None, + top_p: None, + n: None, + stream: None, + stop: None, + max_tokens: None, + presence_penalty: None, + frequency_penalty: None, + logit_bias: None, + user: None, + }; + + let response = client.request(&request)?; + + response + .choices + .into_iter() + .next() + .ok_or(anyhow::anyhow!("No choices returned.")) + .map(|choice| choice.message.content) +} + +/// A message in OpenAI's chat wire format (single `content` string). +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct OpenAiMessage { + pub role: Role, + pub content: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct ChatCompletionRequest { + pub model: String, + pub messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub n: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub presence_penalty: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub frequency_penalty: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub logit_bias: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +impl Endpoint for ChatCompletionRequest { + type Response = ChatCompletionResponse; + + fn req(&self) -> Req { + Req::new(Method::POST, "/v1/chat/completions") + .header("Content-Type", "application/json") + .json(self) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct ChatCompletionResponse { + pub id: String, + pub object: String, + pub created: u64, + pub choices: Vec, + pub usage: Usage, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct ChatChoice { + pub index: u32, + pub message: OpenAiMessage, + pub finish_reason: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct Usage { + pub prompt_tokens: u32, + pub completion_tokens: Option, + pub total_tokens: u32, +} From 2eeeffc1c1421ff700f1216708d989781b33647d Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 12:55:28 -0700 Subject: [PATCH 20/24] prompt: drop the cache-marking comment Co-Authored-By: Claude Opus 4.8 --- src/prompt.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/prompt.rs b/src/prompt.rs index d6ae100..c23b2d5 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -64,8 +64,6 @@ pub fn chat_prefix() -> Vec { ])); ret.push(Message::assistant(sample.result)); } - // This prefix is identical across calls; mark its end so a backend caches - // through it and only the appended user turn is billed each time. if let Some(last) = ret.last_mut() { last.cache = true; } From b4f817bb5fd299a7daf75de068ede8b0e7ed9d05 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 13:00:50 -0700 Subject: [PATCH 21/24] README: sync with the provider changes Drop the removed `max_tokens` config setting, note provider is inferred from keys when unset, fix the API-key link to Anthropic (the default), and reword "still supported" -> "also supported". Co-Authored-By: Claude Opus 4.8 --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 86eb095..273a31e 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,14 @@ This tool calls the Anthropic (Claude) API by default — bring your own API key Use `refac login` to enter it; the key is saved in your home directory for future use. See [your API usage](https://console.anthropic.com/settings/usage). -OpenAI is still supported: set `provider = "openai"` in +OpenAI is also supported: set `provider = "openai"` in `~/.config/refac/config.toml` (or `REFAC_PROVIDER=openai`), then `refac login`. Config (`~/.config/refac/config.toml`, all optional): ```toml -provider = "anthropic" # or "openai" +provider = "anthropic" # or "openai"; inferred from your keys when unset model = "claude-opus-4-8" # default per provider; or set REFAC_MODEL -max_tokens = 16000 # Anthropic only ``` Keys may also be supplied via `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` env vars. @@ -154,7 +153,7 @@ Sincerely, First, make sure you have: - [ ] installed refac -- [ ] entered your [api key](https://platform.openai.com/account/api-keys) using `refac login` +- [ ] entered your [API key](https://console.anthropic.com/settings/keys) using `refac login` ### Emacs From 2804deab8e7609ac9c3ca38436400d0c48bdfa46 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 13:58:30 -0700 Subject: [PATCH 22/24] README: trim the config blurb Co-Authored-By: Claude Opus 4.8 --- README.md | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 273a31e..772bae0 100644 --- a/README.md +++ b/README.md @@ -7,21 +7,9 @@ The workflow: - Run the command, write instructions on what you want changed. - Enjoy the sassy comments. -This tool calls the Anthropic (Claude) API by default — bring your own API key. -Use `refac login` to enter it; the key is saved in your home directory for future -use. See [your API usage](https://console.anthropic.com/settings/usage). - -OpenAI is also supported: set `provider = "openai"` in -`~/.config/refac/config.toml` (or `REFAC_PROVIDER=openai`), then `refac login`. - -Config (`~/.config/refac/config.toml`, all optional): - -```toml -provider = "anthropic" # or "openai"; inferred from your keys when unset -model = "claude-opus-4-8" # default per provider; or set REFAC_MODEL -``` - -Keys may also be supplied via `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` env vars. +Calls Claude by default — bring your own key and run `refac login` (or set +`ANTHROPIC_API_KEY`). For OpenAI, set `REFAC_PROVIDER=openai` and `OPENAI_API_KEY`. +Optional `provider` / `model` config lives in `~/.config/refac/config.toml`. ## SETUP From eca0c2159cbbbc62dfd93428bc26a10c04c38022 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 14:05:21 -0700 Subject: [PATCH 23/24] secrets: write secrets.toml 0600; log provider as the enum Review-loop findings: - the saved secrets file held the API key but was created world-readable (default 0644); create/force it 0600 on unix. - LogEntry.provider was a Debug-formatted string; use the Provider enum so the log matches the config-file casing and stays typed. Co-Authored-By: Claude Opus 4.8 --- src/config_files.rs | 21 ++++++++++++++++++++- src/main.rs | 4 ++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/config_files.rs b/src/config_files.rs index 738ba2b..c5d4712 100644 --- a/src/config_files.rs +++ b/src/config_files.rs @@ -35,7 +35,26 @@ impl Secrets { pub fn save(&self) -> anyhow::Result<()> { let path = base()?.place_config_file("secrets.toml")?; - fs::write(path, toml::to_string(self)?)?; + let contents = toml::to_string(self)?; + // Holds the API key in cleartext — keep it owner-only. + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&path)? + .write_all(contents.as_bytes())?; + // `place_config_file` may have created the file 0644 already, so the + // mode above wouldn't apply; force it. + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; + } + #[cfg(not(unix))] + fs::write(&path, contents)?; Ok(()) } } diff --git a/src/main.rs b/src/main.rs index 0fe5162..27c6f28 100644 --- a/src/main.rs +++ b/src/main.rs @@ -130,7 +130,7 @@ fn refactor( log( LogEntry { - provider: format!("{:?}", provider), + provider, model, selected, transform, @@ -159,7 +159,7 @@ fn log_location(title: &str) -> anyhow::Result { #[derive(Debug, Serialize)] struct LogEntry { - provider: String, + provider: Provider, model: String, selected: String, transform: String, From 610e2dbfdfad965594112f3aa388fcc6788b0258 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 14:07:19 -0700 Subject: [PATCH 24/24] README: regenerate the examples with Opus 4.8 Co-Authored-By: Claude Opus 4.8 --- README.md | 78 ++++++++++++++++++++++++------------------------------- 1 file changed, 34 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 772bae0..dcf598e 100644 --- a/README.md +++ b/README.md @@ -30,31 +30,25 @@ THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG. > refac tor ' def add(a: int, b: int): return a + b -' 'turn this into a command line program that accepts a and b as arguments, printing the result'` -# I've transformed your `add` function into a command-line script that accepts two integer arguments and prints their sum. -# Based on the syntax of your code, I assume you're using Python. If this is incorrect, please let me know. -# Run the script with `python add.py ` where `` and `` are the integers you want to add. +' 'turn this into a command line program that accepts a and b as arguments, printing the result' +# Another riveting addition machine for the ages. I'll spruce it up with type hints and argparse, +# because apparently I have standards even when you don't. +# Run it with `python add.py `. # --refac -import sys +import argparse -def add(a: int, b: int): - return a + b -if __name__ == "__main__": - if len(sys.argv) != 3: - print("Usage: python add.py ") - sys.exit(1) +def add(a: int, b: int) -> int: + return a + b - try: - a = int(sys.argv[1]) - b = int(sys.argv[2]) - except ValueError: - print("Both arguments must be integers.") - sys.exit(1) - result = add(a, b) - print(f"The result of {a} + {b} is {result}.") +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Add two integers.") + parser.add_argument("a", type=int, help="The first integer.") + parser.add_argument("b", type=int, help="The second integer.") + args = parser.parse_args() + print(add(args.a, args.b)) > refac tor ' fn factorial(a: usize) -> usize { @@ -65,11 +59,12 @@ fn factorial(a: usize) -> usize { } } ' 'switch to an iterative implementation' +// Recursion is elegant and all, but why summon the stack-overflow goblins when a loop will do? +// Here's your iterative factorial, served fresh. +// --refac fn factorial(a: usize) -> usize { - // Switched to an iterative implementation using a loop. - // --refac let mut result = 1; - for i in 1..=a { + for i in 2..=a { result *= i; } result @@ -77,30 +72,28 @@ fn factorial(a: usize) -> usize { > refac tor ' fn factorial(a: usize) -> usize { - let mut result = 1; - for i in 1..=a { - result *= i; - } - result + let mut result = 1; + for i in 1..=a { + result *= i; + } + result } ' 'use an iterator and .fold()' +// Look at you, embracing the functional style! I'm getting all warm and fuzzy inside. +// --refac fn factorial(a: usize) -> usize { - (1..=a).fold(1, |acc, x| acc * x) + (1..=a).fold(1, |result, i| result * i) } > refac tor '' 'implement hello world in rust' -// Here's a simple "Hello, World!" program in Rust. -// Compile it with `rustc hello_world.rs` and run it with `./hello_world`. +// Behold! A masterpiece of unparalleled complexity. Try not to strain yourself. // --refac - fn main() { - println!("Hello, World!"); + println!("Hello, world!"); } > refac tor '' 'write fizzbuzz in rust' -// Oh, great. The ultimate test of awesomeness. *eyeroll* -// Fine. Here's a sparkling FizzBuzz implementation forged with pure Rustian might just for you. -// Run with `cargo run`. +// FizzBuzz, the eternal rite of passage. You've ascended to junior dev status, congrats. // --refac fn main() { for i in 1..=100 { @@ -116,25 +109,22 @@ fn main() { > refac tor ' Hey [Name], -I\'m writing to ask if you can lend me $100. I promise I\'ll pay you back as soon as possible. +I'm writing to ask if you can lend me $100. I promise I'll pay you back as soon as possible. Thanks, [Your Name] ' 'make the email more formal and appropriate for a professional setting' -Subject: Request for Temporary Financial Assistance - Dear [Name], -I hope this email finds you well. I am reaching out to request a temporary financial loan of $100. Due to unforeseen circumstances, I find myself in a situation where I require immediate support. Please know that I do not take this request lightly, and I understand the responsibility that comes with borrowing money. +I hope this message finds you well. I am writing to respectfully request a short-term loan of $100. I would be most grateful for your assistance, and I assure you that I will repay the amount at the earliest possible opportunity. -I would like to emphasize my commitment to repaying the loan as promptly as possible. Your assistance during this challenging time would be greatly appreciated, and I assure you that I am doing everything in my power to improve my financial situation. +Please let me know if this is something you would be willing to consider. I am happy to discuss any terms or arrangements that would be convenient for you. -Should you have any concerns, questions, or require additional information, please do not hesitate to reach out to me. I am more than happy to discuss the matter further. +Thank you very much for your time and consideration. -Thank you very much for your time and consideration. I eagerly await your response. - -Sincerely, +Best regards, +[Your Name] ``` ## Using Refac From Your Favorite Text Editor