use std::env;
use std::time::Duration;
pub mod anthropic;
pub mod anthropic_sdk;
pub mod local;
pub mod openai;
pub mod openai_sdk;
use crate::api::ContentBlock;
use crate::errors::ProviderError;
use crate::types::ModelId;
use reqwest::Client;
use serde_json::{Value, json};
pub use looprs_core::ports::InferenceProvider as LLMProvider;
pub use looprs_core::ports::inference_provider::{InferenceRequest, InferenceResponse, Usage};
const DEFAULT_TIMEOUT_SECS: u64 = 120;
pub(crate) struct ProviderHttpClient {
client: Client,
}
impl ProviderHttpClient {
pub fn new(timeout_secs: u64) -> Result<Self, ProviderError> {
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()?;
Ok(Self { client })
}
pub fn default() -> Result<Self, ProviderError> {
Self::new(DEFAULT_TIMEOUT_SECS)
}
pub fn client(&self) -> &Client {
&self.client
}
}
#[derive(Debug, Clone, Default)]
pub struct ProviderOverrides {
pub model: Option<ModelId>,
}
pub(crate) fn is_reasoning_model(model: &str) -> bool {
model.starts_with("o1") || model.starts_with("o3")
}
pub(crate) fn supports_temperature(model: &str) -> bool {
!is_reasoning_model(model) && !model.starts_with("gpt-5")
}
pub(crate) fn convert_to_openai_messages(msg: &crate::api::Message) -> Vec<Value> {
let mut messages = Vec::new();
let mut text_parts = Vec::new();
let mut tool_calls = Vec::new();
for block in &msg.content {
match block {
ContentBlock::Text { text } => {
text_parts.push(text.clone());
}
ContentBlock::ToolUse { id, name, input } => {
tool_calls.push(json!({
"id": id.as_str(),
"type": "function",
"function": {
"name": name.as_str(),
"arguments": serde_json::to_string(input).unwrap_or_default()
}
}));
}
ContentBlock::ToolResult {
tool_use_id,
content: result_content,
} => {
messages.push(json!({
"role": "tool",
"tool_call_id": tool_use_id.as_str(),
"content": result_content
}));
}
}
}
if !text_parts.is_empty() || !tool_calls.is_empty() {
let mut main_msg = json!({
"role": msg.role,
});
if !text_parts.is_empty() {
main_msg["content"] = json!(text_parts.join("\n"));
} else if tool_calls.is_empty() {
main_msg["content"] = json!("");
}
if !tool_calls.is_empty() {
main_msg["tool_calls"] = json!(tool_calls);
}
messages.insert(0, main_msg);
}
messages
}
pub async fn create_provider_with_overrides(
overrides: ProviderOverrides,
) -> Result<Box<dyn LLMProvider>, ProviderError> {
let _ = dotenvy::dotenv();
let config_file = crate::config_file::ProviderConfig::load().ok();
if let Ok(provider_name) = env::var("PROVIDER") {
return create_provider_by_name(&provider_name, &config_file, overrides).await;
}
if let Some(config) = config_file.as_ref()
&& let Some(provider_name) = &config.provider
{
return create_provider_by_name(provider_name, &config_file, overrides).await;
}
if env::var("ANTHROPIC_API_KEY").is_ok() {
return create_provider_by_name("anthropic", &config_file, overrides).await;
}
if env::var("OPENAI_API_KEY").is_ok() {
return create_provider_by_name("openai", &config_file, overrides).await;
}
if local::LocalProvider::is_available().await {
return create_provider_by_name("local", &config_file, overrides).await;
}
Err(ProviderError::NoProviderConfigured)
}
fn resolve_model(
config_section: &str,
config_file: &Option<crate::config_file::ProviderConfig>,
overrides: &ProviderOverrides,
) -> Option<ModelId> {
overrides
.model
.clone()
.or_else(|| env::var("MODEL").ok().map(ModelId::new))
.or_else(|| {
config_file
.as_ref()
.and_then(|c| c.merged_settings(config_section).model)
.map(ModelId::new)
})
}
async fn create_provider_by_name(
name: &str,
config_file: &Option<crate::config_file::ProviderConfig>,
overrides: ProviderOverrides,
) -> Result<Box<dyn LLMProvider>, ProviderError> {
match name.to_lowercase().as_str() {
"anthropic" => {
let key = env::var("ANTHROPIC_API_KEY")
.map_err(|_| ProviderError::MissingApiKey("anthropic".to_string()))?;
let model = resolve_model("anthropic", config_file, &overrides);
Ok(Box::new(anthropic::AnthropicProvider::new_with_model(
key, model,
)?))
}
"anthropic-sdk" | "claude-sdk" => {
let key = env::var("ANTHROPIC_API_KEY")
.map_err(|_| ProviderError::MissingApiKey("anthropic".to_string()))?;
let model = resolve_model("anthropic", config_file, &overrides);
Ok(Box::new(
anthropic_sdk::AnthropicSdkProvider::new_with_model(key, model)?,
))
}
"openai" => {
let key = env::var("OPENAI_API_KEY")
.map_err(|_| ProviderError::MissingApiKey("openai".to_string()))?;
let model = resolve_model("openai", config_file, &overrides);
Ok(Box::new(openai::OpenAIProvider::new_with_model(
key, model,
)?))
}
"openai-sdk" => {
let key = env::var("OPENAI_API_KEY")
.map_err(|_| ProviderError::MissingApiKey("openai".to_string()))?;
let model = resolve_model("openai", config_file, &overrides);
Ok(Box::new(openai_sdk::OpenAISdkProvider::new_with_model(
key, model,
)?))
}
"local" | "ollama" => {
let model = resolve_model("local", config_file, &overrides);
Ok(Box::new(local::LocalProvider::new_with_model(model)?))
}
other => Err(ProviderError::Config(format!("Unknown provider: {other}"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::Message;
use looprs_core::types::{ToolId, ToolName};
#[test]
fn convert_to_openai_messages_text_only() {
let msg = Message::user("hello");
let result = convert_to_openai_messages(&msg);
assert_eq!(result.len(), 1);
assert_eq!(result[0]["role"], "user");
assert_eq!(result[0]["content"], "hello");
}
#[test]
fn convert_to_openai_messages_tool_result() {
let msg = Message::tool_results(vec![ContentBlock::ToolResult {
tool_use_id: ToolId::new("call_1"),
content: "output".into(),
}]);
let result = convert_to_openai_messages(&msg);
assert_eq!(result.len(), 1);
assert_eq!(result[0]["role"], "tool");
assert_eq!(result[0]["tool_call_id"], "call_1");
}
#[test]
fn convert_to_openai_messages_with_tool_use() {
let msg = Message::assistant(vec![ContentBlock::ToolUse {
id: ToolId::new("call_2"),
name: ToolName::new("read"),
input: json!({"path": "foo.rs"}),
}]);
let result = convert_to_openai_messages(&msg);
assert_eq!(result.len(), 1);
assert_eq!(result[0]["tool_calls"][0]["function"]["name"], "read");
}
#[test]
fn is_reasoning_model_detects_o1_o3() {
assert!(is_reasoning_model("o1-preview"));
assert!(is_reasoning_model("o3-mini"));
assert!(!is_reasoning_model("gpt-4o"));
}
#[test]
fn supports_temperature_excludes_reasoning_and_gpt5() {
assert!(supports_temperature("gpt-4o"));
assert!(!supports_temperature("o1-preview"));
assert!(!supports_temperature("gpt-5-mini"));
}
}