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..754d0b1 --- /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. +/// +/// 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; +} + +/// 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