use crate::api::error::ApiError;
#[cfg(feature = "openai")]
pub mod openai;
#[cfg(feature = "anthropic")]
pub mod anthropic;
#[cfg(feature = "gemini")]
pub mod gemini;
#[cfg(feature = "openai")]
pub use openai::OpenAiClient;
#[cfg(feature = "anthropic")]
pub use anthropic::AnthropicClient;
#[cfg(feature = "gemini")]
pub use gemini::GeminiClient;
#[cfg(feature = "ollama")]
const OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
#[cfg(feature = "deepseek")]
const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1";
#[cfg(feature = "deepseek")]
const DEEPSEEK_DEFAULT_MODEL: &str = "deepseek-chat";
#[cfg(feature = "grok")]
const GROK_BASE_URL: &str = "https://api.x.ai/v1";
#[cfg(feature = "grok")]
const GROK_DEFAULT_MODEL: &str = "grok-beta";
#[cfg(feature = "zai")]
const ZAI_BASE_URL: &str = "https://api.z.ai/api/anthropic";
#[cfg(feature = "zai")]
const ZAI_DEFAULT_MODEL: &str = "glm-4.7";
fn env_or_fallback(primary: &str, fallback: &str) -> Option<String> {
std::env::var(primary)
.or_else(|_| std::env::var(fallback))
.ok()
}
fn env_or_default(name: &str, default: &str) -> String {
std::env::var(name).unwrap_or_else(|_| default.into())
}
fn require_api_key(primary: &str, fallback: Option<&str>) -> Result<String, ApiError> {
if let Some(fb) = fallback {
if let Some(val) = env_or_fallback(primary, fb) {
return Ok(val);
}
} else if let Ok(val) = std::env::var(primary) {
return Ok(val);
}
Err(ApiError::auth_invalid_key(format!("{primary} not set")))
}
#[cfg(feature = "ollama")]
pub fn ollama(model: &str) -> Result<OpenAiClient, ApiError> {
let base = env_or_default("OLLAMA_BASE_URL", OLLAMA_BASE_URL);
let api_key = env_or_default("OLLAMA_API_KEY", "ollama");
OpenAiClient::builder()
.api_key(api_key)
.base_url(base)
.model(model)
.build()
}
#[cfg(feature = "deepseek")]
pub fn deepseek() -> Result<OpenAiClient, ApiError> {
let api_key = require_api_key("DEEPSEEK_API_KEY", None)?;
let model = env_or_default("DEEPSEEK_MODEL", DEEPSEEK_DEFAULT_MODEL);
OpenAiClient::builder()
.api_key(api_key)
.base_url(DEEPSEEK_BASE_URL)
.model(model)
.build()
}
#[cfg(feature = "grok")]
pub fn grok() -> Result<OpenAiClient, ApiError> {
let api_key = require_api_key("XAI_API_KEY", Some("GROK_API_KEY"))?;
let model = env_or_default("GROK_MODEL", GROK_DEFAULT_MODEL);
OpenAiClient::builder()
.api_key(api_key)
.base_url(GROK_BASE_URL)
.model(model)
.build()
}
#[cfg(feature = "zai")]
pub fn zai() -> Result<AnthropicClient, ApiError> {
let api_key = require_api_key("ZAI_API_KEY", Some("ZHIPUAI_API_KEY"))?;
let model = env_or_default("ZAI_MODEL", ZAI_DEFAULT_MODEL);
AnthropicClient::builder()
.api_key(api_key)
.base_url(ZAI_BASE_URL)
.model(model)
.build()
}
#[cfg(feature = "openai")]
pub fn self_hosted(base_url: &str, model: &str) -> Result<OpenAiClient, ApiError> {
let api_key = env_or_default("OPENAI_API_KEY", "self-hosted");
OpenAiClient::builder()
.api_key(api_key)
.base_url(base_url)
.model(model)
.build()
}
#[cfg(test)]
mod tests {
use super::*;
macro_rules! env_set {
($($arg:tt)*) => {{
unsafe { std::env::set_var($($arg)*) }
}};
}
macro_rules! env_remove {
($($arg:tt)*) => {{
unsafe { std::env::remove_var($($arg)*) }
}};
}
#[test]
fn env_or_fallback_primary_set() {
env_set!("LOOPCTL_TEST_PRIMARY", "primary-val");
env_remove!("LOOPCTL_TEST_FALLBACK");
assert_eq!(
env_or_fallback("LOOPCTL_TEST_PRIMARY", "LOOPCTL_TEST_FALLBACK"),
Some("primary-val".into())
);
env_remove!("LOOPCTL_TEST_PRIMARY");
}
#[test]
fn env_or_fallback_fallback_used_when_primary_missing() {
env_remove!("LOOPCTL_TEST_PRIMARY2");
env_set!("LOOPCTL_TEST_FALLBACK2", "fallback-val");
assert_eq!(
env_or_fallback("LOOPCTL_TEST_PRIMARY2", "LOOPCTL_TEST_FALLBACK2"),
Some("fallback-val".into())
);
env_remove!("LOOPCTL_TEST_FALLBACK2");
}
#[test]
fn env_or_fallback_none_when_both_missing() {
env_remove!("LOOPCTL_TEST_NEITHER_A");
env_remove!("LOOPCTL_TEST_NEITHER_B");
assert_eq!(
env_or_fallback("LOOPCTL_TEST_NEITHER_A", "LOOPCTL_TEST_NEITHER_B"),
None
);
}
#[test]
fn env_or_default_uses_env_when_set() {
env_set!("LOOPCTL_TEST_DEFAULT", "from-env");
assert_eq!(
env_or_default("LOOPCTL_TEST_DEFAULT", "fallback"),
"from-env"
);
env_remove!("LOOPCTL_TEST_DEFAULT");
}
#[test]
fn env_or_default_uses_default_when_unset() {
env_remove!("LOOPCTL_TEST_DEFAULT2");
assert_eq!(
env_or_default("LOOPCTL_TEST_DEFAULT2", "fallback"),
"fallback"
);
}
#[test]
fn require_api_key_primary_set() {
env_set!("LOOPCTL_TEST_KEY_PRIMARY", "secret");
env_remove!("LOOPCTL_TEST_KEY_FALLBACK");
let key = require_api_key(
"LOOPCTL_TEST_KEY_PRIMARY",
Some("LOOPCTL_TEST_KEY_FALLBACK"),
)
.unwrap();
assert_eq!(key, "secret");
env_remove!("LOOPCTL_TEST_KEY_PRIMARY");
}
#[test]
fn require_api_key_fallback_used() {
env_remove!("LOOPCTL_TEST_KEY_PRIMARY2");
env_set!("LOOPCTL_TEST_KEY_FALLBACK2", "fallback-secret");
let key = require_api_key(
"LOOPCTL_TEST_KEY_PRIMARY2",
Some("LOOPCTL_TEST_KEY_FALLBACK2"),
)
.unwrap();
assert_eq!(key, "fallback-secret");
env_remove!("LOOPCTL_TEST_KEY_FALLBACK2");
}
#[test]
fn require_api_key_no_fallback_set() {
env_set!("LOOPCTL_TEST_KEY_ONLY", "only-val");
let key = require_api_key("LOOPCTL_TEST_KEY_ONLY", None).unwrap();
assert_eq!(key, "only-val");
env_remove!("LOOPCTL_TEST_KEY_ONLY");
}
#[test]
fn require_api_key_errors_when_missing() {
env_remove!("LOOPCTL_TEST_MISSING_KEY");
let err = require_api_key("LOOPCTL_TEST_MISSING_KEY", None).unwrap_err();
assert!(err.to_string().contains("LOOPCTL_TEST_MISSING_KEY"));
}
#[test]
fn require_api_key_errors_when_both_missing() {
env_remove!("LOOPCTL_TEST_MISSING_A");
env_remove!("LOOPCTL_TEST_MISSING_B");
let err =
require_api_key("LOOPCTL_TEST_MISSING_A", Some("LOOPCTL_TEST_MISSING_B")).unwrap_err();
assert!(err.to_string().contains("LOOPCTL_TEST_MISSING_A"));
}
#[cfg(feature = "ollama")]
#[test]
fn ollama_client_builds_with_defaults() {
use crate::api::ApiClient;
env_remove!("OLLAMA_BASE_URL");
let client = ollama("llama3").unwrap();
assert_eq!(client.model(), "llama3");
}
#[cfg(feature = "ollama")]
#[test]
fn ollama_client_respects_base_url_env() {
use crate::api::ApiClient;
env_set!("OLLAMA_BASE_URL", "http://my-host:1234/v1");
let client = ollama("test-model").unwrap();
assert_eq!(client.model(), "test-model");
env_remove!("OLLAMA_BASE_URL");
}
#[cfg(feature = "ollama")]
#[test]
fn ollama_client_uses_api_key_when_set() {
use crate::api::ApiClient;
env_remove!("OLLAMA_BASE_URL");
env_set!("OLLAMA_API_KEY", "my-cloud-key");
let client = ollama("llama3").unwrap();
assert_eq!(client.model(), "llama3");
env_remove!("OLLAMA_API_KEY");
}
#[cfg(feature = "ollama")]
#[test]
fn ollama_client_defaults_to_local_without_key() {
use crate::api::ApiClient;
env_remove!("OLLAMA_BASE_URL");
env_remove!("OLLAMA_API_KEY");
let client = ollama("llama3").unwrap();
assert_eq!(client.model(), "llama3");
}
#[cfg(feature = "openai")]
#[test]
fn self_hosted_client_builds() {
use crate::api::ApiClient;
let client = self_hosted("http://localhost:8080/v1", "my-model").unwrap();
assert_eq!(client.model(), "my-model");
}
}