use anyhow::{anyhow, Context};
use serde::{Deserialize, Serialize};
use std::time::Duration;
pub trait LlmBackend: Send + Sync {
fn complete(&self, prompt: &str, max_tokens: u32) -> anyhow::Result<String>;
fn name(&self) -> &'static str;
}
pub fn backend_from_env(explicit: Option<&str>) -> anyhow::Result<Option<Box<dyn LlmBackend>>> {
let name = explicit
.map(str::to_string)
.or_else(|| std::env::var("TJ_BACKEND").ok())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "claude-p".to_string());
match name.trim() {
"claude-p" | "claude" | "agent-sdk" => {
if crate::classifier::agent_sdk::claude_on_path() {
Ok(Some(Box::new(ClaudeCliBackend::from_env())))
} else {
Ok(None)
}
}
"anthropic" | "api" => match std::env::var("ANTHROPIC_API_KEY") {
Ok(key) if !key.is_empty() => Ok(Some(Box::new(AnthropicBackend::new(key)))),
_ => Ok(None),
},
"openai" | "codex" => match std::env::var("OPENAI_API_KEY") {
Ok(key) if !key.is_empty() => Ok(Some(Box::new(OpenAiBackend::openai(key)))),
_ => Ok(None),
},
"ollama" => Ok(Some(Box::new(OpenAiBackend::ollama()))),
other => Err(anyhow!(
"unknown backend '{other}' (expected: claude-p, anthropic, openai, ollama)"
)),
}
}
pub struct ClaudeCliBackend {
model: String,
}
impl ClaudeCliBackend {
pub fn from_env() -> Self {
let model = std::env::var("TJ_CONSOLIDATE_MODEL")
.unwrap_or_else(|_| crate::classifier::agent_sdk::DEFAULT_MODEL.to_string());
Self { model }
}
}
impl LlmBackend for ClaudeCliBackend {
fn complete(&self, prompt: &str, _max_tokens: u32) -> anyhow::Result<String> {
crate::classifier::agent_sdk::run_claude_json(
&crate::classifier::agent_sdk::ClaudeBinaryStdinRunner,
&self.model,
prompt,
)
}
fn name(&self) -> &'static str {
"claude-p"
}
}
pub struct AnthropicBackend {
api_key: String,
model: String,
base_url: String,
timeout: Duration,
}
impl AnthropicBackend {
pub fn new(api_key: String) -> Self {
let model = std::env::var("TJ_CONSOLIDATE_MODEL")
.unwrap_or_else(|_| "claude-haiku-4-5-20251001".to_string());
let base_url = std::env::var("TJ_CONSOLIDATE_BASE_URL")
.unwrap_or_else(|_| "https://api.anthropic.com".to_string());
Self {
api_key,
model,
base_url,
timeout: Duration::from_secs(60),
}
}
}
#[derive(Serialize)]
struct AnthropicReq<'a> {
model: &'a str,
max_tokens: u32,
messages: Vec<AnthropicMsg<'a>>,
}
#[derive(Serialize)]
struct AnthropicMsg<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Deserialize)]
struct AnthropicResp {
content: Vec<AnthropicBlock>,
}
#[derive(Deserialize)]
struct AnthropicBlock {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
text: String,
}
impl LlmBackend for AnthropicBackend {
fn complete(&self, prompt: &str, max_tokens: u32) -> anyhow::Result<String> {
let body = AnthropicReq {
model: &self.model,
max_tokens,
messages: vec![AnthropicMsg {
role: "user",
content: prompt,
}],
};
let resp: AnthropicResp = ureq::post(&format!("{}/v1/messages", self.base_url))
.timeout(self.timeout)
.set("x-api-key", &self.api_key)
.set("anthropic-version", "2023-06-01")
.set("content-type", "application/json")
.send_json(serde_json::to_value(&body)?)
.context("Anthropic API request failed")?
.into_json()
.context("decode Anthropic response")?;
resp.content
.iter()
.find(|b| b.kind == "text")
.map(|b| b.text.clone())
.ok_or_else(|| anyhow!("no text content in Anthropic response"))
}
fn name(&self) -> &'static str {
"anthropic"
}
}
pub struct OpenAiBackend {
api_key: Option<String>,
model: String,
base_url: String,
label: &'static str,
timeout: Duration,
}
impl OpenAiBackend {
pub fn openai(api_key: String) -> Self {
Self {
api_key: Some(api_key),
model: std::env::var("TJ_OPENAI_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string()),
base_url: std::env::var("TJ_OPENAI_BASE_URL")
.unwrap_or_else(|_| "https://api.openai.com".to_string()),
label: "openai",
timeout: Duration::from_secs(60),
}
}
pub fn ollama() -> Self {
Self {
api_key: None, model: std::env::var("TJ_OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1".to_string()),
base_url: std::env::var("TJ_OLLAMA_URL")
.unwrap_or_else(|_| "http://localhost:11434".to_string()),
label: "ollama",
timeout: Duration::from_secs(120),
}
}
}
#[derive(Serialize)]
struct OpenAiReq<'a> {
model: &'a str,
max_tokens: u32,
messages: Vec<AnthropicMsg<'a>>,
}
#[derive(Deserialize)]
struct OpenAiResp {
choices: Vec<OpenAiChoice>,
}
#[derive(Deserialize)]
struct OpenAiChoice {
message: OpenAiMsg,
}
#[derive(Deserialize)]
struct OpenAiMsg {
#[serde(default)]
content: String,
}
impl LlmBackend for OpenAiBackend {
fn complete(&self, prompt: &str, max_tokens: u32) -> anyhow::Result<String> {
let body = OpenAiReq {
model: &self.model,
max_tokens,
messages: vec![AnthropicMsg {
role: "user",
content: prompt,
}],
};
let mut req = ureq::post(&format!("{}/v1/chat/completions", self.base_url))
.timeout(self.timeout)
.set("content-type", "application/json");
if let Some(key) = &self.api_key {
req = req.set("authorization", &format!("Bearer {key}"));
}
let resp: OpenAiResp = req
.send_json(serde_json::to_value(&body)?)
.with_context(|| format!("{} request failed", self.label))?
.into_json()
.context("decode OpenAI-compatible response")?;
resp.choices
.into_iter()
.next()
.map(|c| c.message.content)
.ok_or_else(|| anyhow!("no choices in {} response", self.label))
}
fn name(&self) -> &'static str {
self.label
}
}
#[cfg(test)]
mod tests {
use super::*;
struct EnvGuard(&'static str, Option<String>);
impl EnvGuard {
fn set(k: &'static str, v: &str) -> Self {
let prev = std::env::var(k).ok();
std::env::set_var(k, v);
Self(k, prev)
}
fn unset(k: &'static str) -> Self {
let prev = std::env::var(k).ok();
std::env::remove_var(k);
Self(k, prev)
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.1 {
Some(v) => std::env::set_var(self.0, v),
None => std::env::remove_var(self.0),
}
}
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn unknown_backend_errors() {
let _l = ENV_LOCK.lock().unwrap();
assert!(backend_from_env(Some("nonsense")).is_err());
}
#[test]
fn anthropic_unavailable_without_key_is_none() {
let _l = ENV_LOCK.lock().unwrap();
let _g = EnvGuard::unset("ANTHROPIC_API_KEY");
assert!(backend_from_env(Some("anthropic")).unwrap().is_none());
}
#[test]
fn anthropic_with_key_resolves() {
let _l = ENV_LOCK.lock().unwrap();
let _g = EnvGuard::set("ANTHROPIC_API_KEY", "k");
let b = backend_from_env(Some("anthropic")).unwrap().unwrap();
assert_eq!(b.name(), "anthropic");
}
#[test]
fn ollama_always_resolves_no_key() {
let _l = ENV_LOCK.lock().unwrap();
let b = backend_from_env(Some("ollama")).unwrap().unwrap();
assert_eq!(b.name(), "ollama");
}
#[test]
fn openai_calls_chat_completions_and_parses() {
let mut server = mockito::Server::new();
let m = server
.mock("POST", "/v1/chat/completions")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"choices": [{"message": {"role": "assistant", "content": "hello from openai"}}]
})
.to_string(),
)
.create();
let b = OpenAiBackend {
api_key: Some("k".into()),
model: "gpt-4o-mini".into(),
base_url: server.url(),
label: "openai",
timeout: Duration::from_secs(5),
};
let out = b.complete("hi", 64).unwrap();
m.assert();
assert_eq!(out, "hello from openai");
}
#[test]
fn anthropic_calls_messages_and_parses() {
let mut server = mockito::Server::new();
let m = server
.mock("POST", "/v1/messages")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"content": [{"type": "text", "text": "hello from anthropic"}]
})
.to_string(),
)
.create();
let b = AnthropicBackend {
api_key: "k".into(),
model: "claude-haiku-4-5-20251001".into(),
base_url: server.url(),
timeout: Duration::from_secs(5),
};
let out = b.complete("hi", 64).unwrap();
m.assert();
assert_eq!(out, "hello from anthropic");
}
}