use std::sync::{Arc, Mutex};
use serde_json::json;
use crate::backend_router::{BackendError, ChatBackend, Provider, RoutePolicy, Router};
use crate::claude_backend::{ClaudeBackend, DEFAULT_CLAUDE_MODEL};
use crate::inference::InferencePipeline;
const OPENDROP_ROUTER_BASE_URL: &str = "http://127.0.0.1:11434/v1";
struct LocalPipelineBackend {
pipeline: Arc<Mutex<InferencePipeline>>,
}
impl ChatBackend for LocalPipelineBackend {
fn complete(&self, prompt: &str, max_tokens: usize) -> Result<String, BackendError> {
let pipeline = self
.pipeline
.lock()
.map_err(|_| BackendError::Upstream("pipeline lock poisoned".into()))?;
pipeline
.generate(prompt, max_tokens)
.map_err(|e| BackendError::Upstream(format!("local generation failed: {e}")))
}
}
struct ClaudeChatBackend {
backend: ClaudeBackend,
}
impl ChatBackend for ClaudeChatBackend {
fn complete(&self, prompt: &str, max_tokens: usize) -> Result<String, BackendError> {
self.backend
.generate(prompt, max_tokens)
.map_err(BackendError::Upstream)
}
}
struct OpenAiBlockingBackend {
client: reqwest::blocking::Client,
api_key: Option<String>,
base_url: String,
model: String,
}
impl ChatBackend for OpenAiBlockingBackend {
fn complete(&self, prompt: &str, max_tokens: usize) -> Result<String, BackendError> {
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
let body = json!({
"model": self.model,
"messages": [{ "role": "user", "content": prompt }],
"max_tokens": max_tokens,
});
let mut req = self.client.post(&url).json(&body);
if let Some(key) = &self.api_key {
req = req.bearer_auth(key);
}
let resp = req
.send()
.map_err(|e| BackendError::Upstream(format!("openai request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().unwrap_or_default();
return Err(BackendError::Upstream(format!("openai {status}: {text}")));
}
let v: serde_json::Value = resp
.json()
.map_err(|e| BackendError::Upstream(format!("openai bad json: {e}")))?;
v.get("choices")
.and_then(|c| c.get(0))
.and_then(|c| c.get("message"))
.and_then(|m| m.get("content"))
.and_then(|t| t.as_str())
.map(|s| s.to_string())
.ok_or_else(|| BackendError::Upstream("openai response missing content".into()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RouterMode {
Router,
OpenAi,
OpenDrop,
}
fn router_mode_from(backend: &str) -> Option<RouterMode> {
match backend.to_ascii_lowercase().as_str() {
"router" => Some(RouterMode::Router),
"openai" => Some(RouterMode::OpenAi),
"opendrop" => Some(RouterMode::OpenDrop),
_ => None,
}
}
fn openai_base_url_for(mode: RouterMode) -> String {
if let Ok(url) = std::env::var("OPENAI_BASE_URL") {
return url;
}
if let Ok(url) = std::env::var("OPENAI_API_BASE") {
return url;
}
match mode {
RouterMode::OpenDrop => OPENDROP_ROUTER_BASE_URL.to_string(),
RouterMode::Router | RouterMode::OpenAi => "https://api.openai.com/v1".to_string(),
}
}
fn claude_from_key() -> Option<ClaudeBackend> {
let api_key = std::env::var("ANTHROPIC_API_KEY")
.ok()
.filter(|k| !k.trim().is_empty())?;
let model =
std::env::var("AXIOM_CLAUDE_MODEL").unwrap_or_else(|_| DEFAULT_CLAUDE_MODEL.to_string());
let default_system = std::env::var("AXIOM_CLAUDE_SYSTEM").ok();
Some(ClaudeBackend::new(model, api_key, default_system))
}
pub fn router_from_env(pipeline: Arc<Mutex<InferencePipeline>>) -> Option<Router> {
let backend = std::env::var("AXIOM_BACKEND").unwrap_or_default();
let mode = router_mode_from(&backend)?;
match mode {
RouterMode::Router => {
let mut router = Router::new(RoutePolicy::default()).with(
Provider::Local,
Box::new(LocalPipelineBackend { pipeline }),
);
if let Some(claude) = claude_from_key() {
router = router.with(
Provider::Anthropic,
Box::new(ClaudeChatBackend { backend: claude }),
);
}
let openai_key = std::env::var("OPENAI_API_KEY")
.ok()
.filter(|k| !k.trim().is_empty());
let has_custom_base = std::env::var("OPENAI_BASE_URL").is_ok()
|| std::env::var("OPENAI_API_BASE").is_ok();
if openai_key.is_some() || has_custom_base {
let model =
std::env::var("AXIOM_OPENAI_MODEL").unwrap_or_else(|_| "gpt-4o".to_string());
router = router.with(
Provider::OpenAi,
Box::new(OpenAiBlockingBackend {
client: reqwest::blocking::Client::new(),
api_key: openai_key,
base_url: openai_base_url_for(mode),
model,
}),
);
}
Some(router)
}
RouterMode::OpenAi | RouterMode::OpenDrop => {
let policy = RoutePolicy {
code_repair: Provider::OpenAi,
reasoning: Provider::OpenAi,
general: Provider::OpenAi,
failover: vec![Provider::OpenAi, Provider::Local],
consensus: false,
};
let openai_key = std::env::var("OPENAI_API_KEY")
.ok()
.filter(|k| !k.trim().is_empty());
let model =
std::env::var("AXIOM_OPENAI_MODEL").unwrap_or_else(|_| "gpt-4o".to_string());
let router = Router::new(policy)
.with(
Provider::OpenAi,
Box::new(OpenAiBlockingBackend {
client: reqwest::blocking::Client::new(),
api_key: openai_key,
base_url: openai_base_url_for(mode),
model,
}),
)
.with(
Provider::Local,
Box::new(LocalPipelineBackend { pipeline }),
);
Some(router)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn router_mode_parsing() {
assert_eq!(router_mode_from("router"), Some(RouterMode::Router));
assert_eq!(router_mode_from("OpenAI"), Some(RouterMode::OpenAi));
assert_eq!(router_mode_from("opendrop"), Some(RouterMode::OpenDrop));
assert_eq!(router_mode_from("bootstrap"), None);
assert_eq!(router_mode_from("claude"), None);
}
#[test]
fn opendrop_base_url_defaults_local() {
if std::env::var("OPENAI_BASE_URL").is_err()
&& std::env::var("OPENAI_API_BASE").is_err()
{
assert_eq!(
openai_base_url_for(RouterMode::OpenDrop),
OPENDROP_ROUTER_BASE_URL
);
assert!(openai_base_url_for(RouterMode::OpenAi).contains("api.openai.com"));
}
}
}