From 291403525580a33a5d3957e3b2b45d877b361d3d Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 14:27:42 -0700 Subject: [PATCH 1/2] Refactor provider dispatch behind a Backend trait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.rs::refactor() matched on Provider, re-derived the API key inline, and called a free anthropic::complete / openai::complete — a match-and-duplicate that grows worse with each new call shape. Introduce a Backend trait and a single resolve() that turns a provider choice into a key-bearing, callable backend, so the rest of refac stays provider-agnostic. No behavior change: wire formats, error paths, and "no key" messages are identical. Paves the way for an Anthropic-only tool/edit capability (a separate trait, hence Box over a closed enum). Co-Authored-By: Claude Opus 4.8 --- src/anthropic.rs | 21 +++++++++++++++- src/backend.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 20 ++------------- src/openai.rs | 21 +++++++++++++++- 4 files changed, 106 insertions(+), 20 deletions(-) create mode 100644 src/backend.rs diff --git a/src/anthropic.rs b/src/anthropic.rs index 4ea7c89..b5b3ed6 100644 --- a/src/anthropic.rs +++ b/src/anthropic.rs @@ -7,9 +7,28 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::api::{Message, Role}; +use crate::backend::Backend; const MAX_TOKENS: u32 = 80000; +/// The Anthropic backend: an API key and the model to call. +pub struct Anthropic { + key: String, + model: String, +} + +impl Anthropic { + pub fn new(key: String, model: String) -> Self { + Anthropic { key, model } + } +} + +impl Backend for Anthropic { + fn complete(&self, messages: &[Message]) -> anyhow::Result { + send(&self.key, &self.model, messages) + } +} + /// Anthropic 400s on an empty text block, so render empty fields as a visible /// placeholder. fn field_or_placeholder(field: &str) -> &str { @@ -77,7 +96,7 @@ enum ResponseBlock { } /// Send a chat-style prompt to the Claude Messages API and return the text. -pub fn complete(api_key: &str, model: &str, messages: &[Message]) -> anyhow::Result { +fn send(api_key: &str, model: &str, messages: &[Message]) -> anyhow::Result { let req = build_request(model, messages); tracing::debug!( diff --git a/src/backend.rs b/src/backend.rs new file mode 100644 index 0000000..9d9d0ed --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,64 @@ +//! The model-backend interface: one trait both providers implement, plus the +//! single place where a `Provider` choice is turned into a ready-to-call, +//! key-bearing backend. + +use anyhow::Result; + +use crate::anthropic::Anthropic; +use crate::api::Message; +use crate::config_files::{Provider, Secrets}; +use crate::openai::Openai; + +/// A resolved model backend — provider, key, and model already settled. Callers +/// hand it refac's provider-agnostic [`Message`]s and get back the completion. +/// +/// Returned as `Box` rather than a closed `enum` on purpose: +/// upcoming tool/function-call edits are an Anthropic-only capability, which a +/// trait expresses as a separate `Edits` trait that only `Anthropic` implements +/// — no enum arm that has to fake "unsupported" at runtime. Keep it `dyn`. +pub trait Backend { + /// Send the conversation and return the model's text output. + fn complete(&self, messages: &[Message]) -> Result; +} + +/// Turn a resolved provider + model into a callable backend, failing if that +/// provider's API key is missing. This is the one spot that knows how each +/// provider sources its key, so the rest of refac stays provider-agnostic. +pub fn resolve(provider: Provider, model: &str, secrets: &Secrets) -> Result> { + match provider { + Provider::Anthropic => { + let key = secrets.anthropic_api_key.clone().ok_or_else(|| { + anyhow::anyhow!("No Anthropic API key found. Set ANTHROPIC_API_KEY or run 'refac login'.") + })?; + Ok(Box::new(Anthropic::new(key, model.to_string()))) + } + Provider::Openai => { + let key = secrets.openai_api_key.clone().ok_or_else(|| { + anyhow::anyhow!("No OpenAI API key found. Set OPENAI_API_KEY or run 'refac login'.") + })?; + Ok(Box::new(Openai::new(key, model.to_string()))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_errors_without_a_key() { + let secrets = Secrets::default(); + assert!(resolve(Provider::Anthropic, "m", &secrets).is_err()); + assert!(resolve(Provider::Openai, "m", &secrets).is_err()); + } + + #[test] + fn resolve_succeeds_with_the_matching_key() { + let secrets = Secrets { + anthropic_api_key: Some("a".into()), + openai_api_key: Some("o".into()), + }; + assert!(resolve(Provider::Anthropic, "m", &secrets).is_ok()); + assert!(resolve(Provider::Openai, "m", &secrets).is_ok()); + } +} diff --git a/src/main.rs b/src/main.rs index 27c6f28..6b0697f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod anthropic; mod api; mod api_client; +mod backend; mod config_files; mod openai; mod prompt; @@ -109,24 +110,7 @@ fn refactor( let provider = config.provider(sc); let model = config.model(provider); - let output = match 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, &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)? - } - }; + let output = backend::resolve(provider, &model, sc)?.complete(&messages)?; log( LogEntry { diff --git a/src/openai.rs b/src/openai.rs index 1f78d00..985a21d 100644 --- a/src/openai.rs +++ b/src/openai.rs @@ -7,9 +7,28 @@ use serde::{Deserialize, Serialize}; use crate::api::{Message, Role}; use crate::api_client::{Client, Endpoint, Req}; +use crate::backend::Backend; + +/// The OpenAI backend: an API key and the model to call. +pub struct Openai { + key: String, + model: String, +} + +impl Openai { + pub fn new(key: String, model: String) -> Self { + Openai { key, model } + } +} + +impl Backend for Openai { + fn complete(&self, messages: &[Message]) -> anyhow::Result { + send(&self.key, &self.model, messages) + } +} /// 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 { +fn send(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 From c781bf3bb9626b075390168184b65087b1cf0b64 Mon Sep 17 00:00:00 2001 From: bddap-bot Date: Tue, 2 Jun 2026 14:31:01 -0700 Subject: [PATCH 2/2] =?UTF-8?q?backend:=20fix=20doc=20comment=20=E2=80=94?= =?UTF-8?q?=20function-calling=20is=20shared,=20not=20Anthropic-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/backend.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/backend.rs b/src/backend.rs index 9d9d0ed..754d0b1 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -12,10 +12,10 @@ use crate::openai::Openai; /// A resolved model backend — provider, key, and model already settled. Callers /// hand it refac's provider-agnostic [`Message`]s and get back the completion. /// -/// Returned as `Box` rather than a closed `enum` on purpose: -/// upcoming tool/function-call edits are an Anthropic-only capability, which a -/// trait expresses as a separate `Edits` trait that only `Anthropic` implements -/// — no enum arm that has to fake "unsupported" at runtime. Keep it `dyn`. +/// Resolved to `Box` so call sites depend only on the interface, +/// never on which provider answered. The trait is where the upcoming tool / +/// function-call round-trip lands (both providers support it), keeping the +/// edit loop provider-agnostic. pub trait Backend { /// Send the conversation and return the model's text output. fn complete(&self, messages: &[Message]) -> Result;